Compare commits

...

164 Commits

Author SHA1 Message Date
binwiederhier
db1a1fec0c Custom HTTP response writer 2023-02-17 09:07:57 -05:00
binwiederhier
7fb6f794e5 Merge branch 'main' of github.com:binwiederhier/ntfy 2023-02-17 08:14:15 -05:00
binwiederhier
df68b0cb43 Blog post 2023-02-17 08:13:50 -05:00
Philipp C. Heckel
ca49fd1161 Merge pull request #613 from danroc/main
Fix login, signup and reservation environment variables in documentation
2023-02-17 06:47:29 -05:00
Philipp C. Heckel
bb3f17ada2 Merge pull request #614 from academo/academo/add-grafana-ntfy-integration
Add integration for Grafana Alerting webhook
2023-02-17 06:46:47 -05:00
Esteban Beltran
d18c61f0da Add integration for Grafana Alerting webhook 2023-02-17 12:42:32 +01:00
Daniel Rocha
92cfc04024 Fix login, signup and reservation environment variables 2023-02-17 10:53:09 +01:00
binwiederhier
2d0ce79011 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-16 22:48:49 -05:00
ButterflyOfFire
c6e091a754 Translated using Weblate (Arabic)
Currently translated at 22.7% (43 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ar/
2023-02-16 23:39:34 +01:00
binwiederhier
c8c16eb8e6 Fix failing test 2023-02-16 16:32:43 -05:00
binwiederhier
c815b183d4 Bump release notes 2023-02-16 16:14:41 -05:00
binwiederhier
b8e976f4f6 Bump to 2.0.0 2023-02-16 14:21:19 -05:00
binwiederhier
6c51b7558a Fine tuning error messages, add --ignore-exists flag to tier/user command 2023-02-16 10:35:23 -05:00
binwiederhier
c4e4cc5aa7 Tiny release notes fix 2023-02-15 19:55:03 -05:00
binwiederhier
5e90ff7db0 Docs drop shadow in dark mode 2023-02-15 19:52:03 -05:00
ButterflyOfFire
6451762508 Added translation using Weblate (Arabic) 2023-02-15 22:44:07 +01:00
binwiederhier
fda90c217f Bump 2023-02-15 15:41:41 -05:00
binwiederhier
94066c24dc Docs docs docs 2023-02-15 15:39:01 -05:00
binwiederhier
76d46ec646 Minor tweaks 2023-02-15 10:55:01 -05:00
binwiederhier
e90f52f375 Merge branch 'main' into user-account 2023-02-14 23:24:41 -05:00
binwiederhier
ca68494203 Forum posts 2023-02-14 23:22:03 -05:00
binwiederhier
396e61cdb3 Bump go build version in CI 2023-02-14 22:00:04 -05:00
binwiederhier
dfaab8c386 Bump version 2023-02-14 21:45:03 -05:00
binwiederhier
0df3e3e4f5 Merge branch 'main' into user-account 2023-02-14 21:22:46 -05:00
binwiederhier
f2f5a06be1 Bump JS deps 2023-02-14 20:58:29 -05:00
binwiederhier
8d7ff4d7db SMTP server tests 2023-02-14 20:56:02 -05:00
binwiederhier
9f052bdf8b Merge branch 'main' into smtp-lib-upgrade 2023-02-14 14:44:09 -05:00
binwiederhier
5472c8513f Release notes 2023-02-14 14:40:41 -05:00
binwiederhier
c028ec9083 Merge branch 'patch-1' 2023-02-14 14:39:34 -05:00
binwiederhier
31a87935a5 Refine iOS docs 2023-02-14 14:39:22 -05:00
binwiederhier
80292f1f4d Tiny changes 2023-02-14 14:26:30 -05:00
binwiederhier
66cf54e458 Fix delayed messages expiry, thanks to @karmanyaahm 2023-02-14 14:05:41 -05:00
binwiederhier
610adb062b More docs 2023-02-14 13:58:49 -05:00
binwiederhier
70aa384bc3 Docs for access tokens 2023-02-13 21:35:58 -05:00
binwiederhier
355424c0da Fix trace logging 2023-02-13 13:20:05 -05:00
binwiederhier
9b118e8085 Merge branch 'main' into user-account 2023-02-12 21:14:50 -05:00
binwiederhier
9e20ee35e1 Thanks to @overtone1000 and @Joachim256 for your sponsorship and donation 2023-02-12 21:13:26 -05:00
binwiederhier
0d4ef18358 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-12 21:11:16 -05:00
SticksDev
8bde80a3d2 Add iOS docs to the dev docs
Imports old dev docs
Also adds my currently open PR #10 on the docs to improve them.
2023-02-12 21:08:37 -05:00
binwiederhier
bed60b71ff Tester feedback 2023-02-12 21:05:24 -05:00
binwiederhier
cc309e87e9 Remove awkward subscription id 2023-02-12 14:09:44 -05:00
binwiederhier
9131d3d521 Token tests 2023-02-12 12:19:46 -05:00
binwiederhier
6b4971786f Fix intermittent test failure; add test for expiring messages after reservation removal 2023-02-12 12:08:56 -05:00
binwiederhier
1f010acb30 Tests for manager.go 2023-02-12 08:29:44 -05:00
binwiederhier
8bf64d8723 A few manager tests 2023-02-11 22:14:09 -05:00
binwiederhier
73b0161ff7 Remove self-review todo 2023-02-11 20:45:04 -05:00
binwiederhier
4cbf1f5371 Derp 2023-02-11 20:38:13 -05:00
binwiederhier
e5a33523d9 Why is this so hard 2023-02-11 14:32:50 -05:00
binwiederhier
224c54b1a2 Fix UI bug with publish dialog 2023-02-11 14:13:10 -05:00
Rycoh
020f561ad4 Translated using Weblate (Romanian)
Currently translated at 4.7% (9 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ro/
2023-02-11 19:36:39 +01:00
binwiederhier
669d269fd9 Popup click should not open page 2023-02-11 10:52:19 -05:00
binwiederhier
b026e45189 Self-review (cont'd) 2023-02-11 10:49:37 -05:00
binwiederhier
7e38419cdb Fix slow test 2023-02-10 21:48:23 -05:00
binwiederhier
cfcc3793c5 Fix 404 race when uploading attachments 2023-02-10 21:44:12 -05:00
binwiederhier
5724bdf436 Fix UI bugs 2023-02-10 21:19:44 -05:00
Rycoh
432cc2003e Added translation using Weblate (Romanian) 2023-02-10 18:55:34 +01:00
binwiederhier
79f9e78c37 More review stuff 2023-02-09 21:51:12 -05:00
binwiederhier
d8dd4c92bf More RWLock. Jeff wins again 2023-02-09 20:49:45 -05:00
binwiederhier
057c4a3239 Jeff saves the day 2023-02-09 19:45:02 -05:00
binwiederhier
dc77efc31a Fix linting 2023-02-09 17:21:12 -05:00
binwiederhier
e6bb5f484c Self-review, round 2 2023-02-09 15:24:12 -05:00
binwiederhier
bcb22d8d4c Added disallowed-topics 2023-02-09 08:32:51 -05:00
binwiederhier
b37cf02a6e Code review (round 1) 2023-02-08 22:57:10 -05:00
binwiederhier
7706bd9845 Fix racing test 2023-02-08 20:00:10 -05:00
binwiederhier
b17a7cfa95 Remove unused var 2023-02-08 15:26:42 -05:00
binwiederhier
e1a4a74905 Auth rate limiter 2023-02-08 15:20:44 -05:00
binwiederhier
3ac315a9e7 FAQs 2023-02-07 23:41:30 -05:00
binwiederhier
fb3e47386c Merge branch 'main' into user-account 2023-02-07 23:30:21 -05:00
binwiederhier
aea8a6d04b Thanks @IanKulin for your donation 2023-02-07 23:23:00 -05:00
binwiederhier
e449f0bda4 Examples 2023-02-07 23:22:29 -05:00
binwiederhier
ff3cb6c5cc Merge branch 'main' of github.com:binwiederhier/ntfy 2023-02-07 23:21:15 -05:00
binwiederhier
2b4f7ab56f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-07 23:21:09 -05:00
Philipp C. Heckel
f5a8216be6 Merge pull request #604 from Y0ngg4n/update-jellyseerr-docs
Update jellyseerr docs
2023-02-07 23:20:48 -05:00
binwiederhier
19324ab232 "Limit reached" chips 2023-02-07 23:18:41 -05:00
binwiederhier
bf96d21d67 Add more logs 2023-02-07 22:45:55 -05:00
binwiederhier
2f0fdf1252 Make logging more efficient 2023-02-07 22:10:51 -05:00
binwiederhier
d44a11325d More visitor log fields 2023-02-07 16:20:49 -05:00
binwiederhier
a32e8abc12 "ntfy tier" CLI command 2023-02-07 12:02:25 -05:00
Yonggan
3779b4a923 Update examples.md 2023-02-07 15:00:21 +01:00
Yonggan
9738e4a225 Fix identation 2023-02-07 14:04:09 +01:00
Yonggan
0905016b1f Update Jellyseerr/Overseerr docs 2023-02-07 14:03:13 +01:00
binwiederhier
e3b39f670f WIP tier CLI 2023-02-06 22:38:22 -05:00
binwiederhier
9b54f63eb1 Error logging 2023-02-06 16:01:32 -05:00
binwiederhier
b5158adb51 Fix linting 2023-02-05 23:53:24 -05:00
binwiederhier
7cc8c81bd8 Continued logging work 2023-02-05 23:34:27 -05:00
binwiederhier
27bd79febf log.go 2023-02-04 21:26:40 -05:00
binwiederhier
5d6051c490 Logging WIP 2023-02-04 21:26:01 -05:00
binwiederhier
a6641980c2 WIP: Logging 2023-02-03 22:21:50 -05:00
Tmpod
5f8ecfaf81 Translated using Weblate (Portuguese)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2023-02-03 14:37:52 +01:00
binwiederhier
af4175a5bc Fix test, fix #598 2023-02-02 19:07:16 -05:00
binwiederhier
8f5ca5220e Merge branch 'main' into user-account 2023-02-02 15:21:51 -05:00
binwiederhier
8da46afab4 Thank you @zoic21 for your donation 2023-02-02 15:21:35 -05:00
binwiederhier
0885951a67 JS error handling 2023-02-02 15:19:37 -05:00
binwiederhier
180a7df1e7 No ripple in dialogs 2023-01-31 22:12:16 -05:00
binwiederhier
07cdf2bc7a Reserve dialogs 2023-01-31 21:39:30 -05:00
binwiederhier
259293f9b3 JS constants 2023-01-30 13:10:45 -05:00
binwiederhier
ef8f7c9884 todo 2023-01-30 12:45:53 -05:00
binwiederhier
b516f99394 Tokens test 2023-01-30 12:19:51 -05:00
binwiederhier
b10b0f8a6a Enable automatic tax 2023-01-30 09:30:51 -05:00
binwiederhier
4ad1099e9f Fix staticcheck 2023-01-29 22:05:50 -05:00
binwiederhier
4f5e40e161 Fix test 2023-01-29 21:51:49 -05:00
binwiederhier
d717bf39ac "ntfy token" CLI 2023-01-29 21:42:40 -05:00
binwiederhier
c12ecb9f21 More tests 2023-01-29 20:11:58 -05:00
binwiederhier
00af52411c More billing unit tests 2023-01-29 16:15:08 -05:00
binwiederhier
f4c54a1643 Associate file downloads with uploader 2023-01-29 15:11:26 -05:00
binwiederhier
40ba143a63 nowrap 2023-01-28 22:13:43 -05:00
binwiederhier
0e36ac84d8 Test anonymous user is same as non-tier user 2023-01-28 21:27:05 -05:00
binwiederhier
92d563371c No more v.user races 2023-01-28 20:43:06 -05:00
binwiederhier
e596834096 Add "last access" to access tokens 2023-01-28 20:29:06 -05:00
binwiederhier
000bf27c87 Speed up tests, hopefully fix races 2023-01-28 09:03:14 -05:00
binwiederhier
b77920bb4b Fix linting errors 2023-01-28 07:40:29 -05:00
binwiederhier
16c14bf709 Add Access Tokens UI 2023-01-27 23:10:59 -05:00
binwiederhier
62140ec001 Rate limiting refactor, race fixes, more tests 2023-01-27 11:33:51 -05:00
binwiederhier
ccc2dd1128 Get rid of v.messages counter 2023-01-27 10:06:48 -05:00
binwiederhier
9e9caee639 (Hopefully) remove statsQueue races 2023-01-27 09:59:16 -05:00
binwiederhier
22c66203a0 Reset message limiter, test 2023-01-27 09:42:54 -05:00
bjornclauw
facf4684ae Translated using Weblate (Dutch)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nl/
2023-01-27 13:44:17 +01:00
binwiederhier
810a29ea72 Fix go vet 2023-01-26 23:10:58 -05:00
binwiederhier
c874a641df Rate limits make sense now! 2023-01-26 22:57:18 -05:00
binwiederhier
a036814d98 Merge branch 'main' into user-account 2023-01-26 11:26:36 -05:00
binwiederhier
2624897efe Merge branch 'main' of github.com:binwiederhier/ntfy 2023-01-26 11:26:23 -05:00
binwiederhier
df6f53a161 Add Shoutrrr integration 2023-01-26 11:26:11 -05:00
binwiederhier
03312559a7 Limiter 2023-01-26 11:24:37 -05:00
binwiederhier
3ab352e253 Merge branch 'main' of github.com:binwiederhier/ntfy into user-account 2023-01-25 22:27:56 -05:00
Philipp C. Heckel
b941551fff Thanks to @billycao for your sponsorship 2023-01-25 22:27:47 -05:00
binwiederhier
593e0748a8 Payment checkout test, rate limit resetting on tier change; failing 2023-01-25 22:26:04 -05:00
binwiederhier
236254d907 Add bandwidth limit to tier; fix display name sync issues 2023-01-25 10:05:54 -05:00
binwiederhier
1771cb3fdb No flickering for sync topic 2023-01-24 15:31:39 -05:00
binwiederhier
eecd689ad5 Fix sync display name and delete after issue 2023-01-24 15:05:19 -05:00
binwiederhier
3e48c86ee9 Merge branch 'main' into user-account 2023-01-24 15:04:44 -05:00
binwiederhier
471775ae49 Remove upx references 2023-01-24 14:57:50 -05:00
binwiederhier
a278297f28 Fix websocket issue 2023-01-24 14:44:14 -05:00
binwiederhier
38a1193523 Merge branch 'main' into user-account 2023-01-24 10:32:24 -05:00
binwiederhier
3d84bdf77b Thanks to @andreapx for your donation 2023-01-24 10:32:11 -05:00
Philipp C. Heckel
8668143127 Update FUNDING.yml 2023-01-24 10:25:56 -05:00
binwiederhier
0d537c8a24 Reserve icons 2023-01-23 20:04:04 -05:00
binwiederhier
bce71cb196 Kill existing subscribers when topic is reserved 2023-01-23 14:05:41 -05:00
binwiederhier
e82a2e518c Add password confirmation to account delete dialog, v1/tiers test 2023-01-23 10:58:39 -05:00
binwiederhier
954d919361 Delayed deletion 2023-01-22 22:21:30 -05:00
Philipp C. Heckel
295bad59bb Merge pull request #594 from jpbaril/patch-1
Elements requiring chown to run non-root Docker
2023-01-22 07:41:24 -05:00
Jean-Philippe Baril
804ee3b298 Elements requiring chown to run non-root Docker
We also have to chown the attachments directory otherwise the docker container does not start and crashes.
BTW, all that should be automated at the container creation.
Because it took me at least an hour to understand that the only way to accomplish that chown command was to first launch the container as root, run the commands, and only then edit docker-compose.yml to add uid/gid. After that I could restart the container and it would now not crash.
2023-01-22 04:32:30 -05:00
binwiederhier
9c082a8331 Introduce text IDs for everything (esp user), to avoid security and accounting issues 2023-01-21 23:15:22 -05:00
binwiederhier
88abd8872d Changing password should confirm the old password 2023-01-21 20:52:16 -05:00
binwiederhier
c66a9851cc Re-add password confirmation 2023-01-21 20:07:39 -05:00
binwiederhier
75c07221ef Added n8n-ntfy 2023-01-21 16:23:15 -05:00
binwiederhier
f443e643ee Merge branch 'main' into user-account 2023-01-21 16:20:39 -05:00
binwiederhier
b82794df05 Thank you @julianlam for your sponsorship 2023-01-21 16:20:24 -05:00
binwiederhier
14f3571e67 More TODOs 2023-01-21 16:19:48 -05:00
binwiederhier
5a7cedce95 More TODOs, hurray 2023-01-21 16:02:56 -05:00
binwiederhier
5310b1d48e Merge branch 'main' into user-account 2023-01-21 15:34:06 -05:00
binwiederhier
167656b38e Blog post 2023-01-21 15:19:52 -05:00
binwiederhier
5d81f875cb Merge branch 'main' of github.com:binwiederhier/ntfy 2023-01-21 15:17:48 -05:00
binwiederhier
6ae200e338 Added Portuguese 2023-01-21 15:17:30 -05:00
binwiederhier
ab6b902fb5 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-01-21 15:14:31 -05:00
Philipp C. Heckel
9f423b01ef Merge pull request #593 from julianlam/patch-1
Add NodeBB to integrations page
2023-01-21 15:14:25 -05:00
Julian Lam
c863c86f4c Update integrations.md
+nodebb
2023-01-21 13:57:42 -05:00
binwiederhier
5b14c76e54 Revert home page to existing page 2023-01-21 08:55:31 -05:00
Philipp C. Heckel
2bd27a5d0b Merge pull request #588 from jamolnng/patch-1
add blog post for unRAID notifications
2023-01-19 13:23:22 -05:00
Philipp C. Heckel
cff8f88920 Update README.md 2023-01-19 12:05:26 -05:00
Jesse Laning
87f5479662 add blog post for unRAID notifications 2023-01-18 23:16:34 -05:00
ssantos
2ec13c64f3 Translated using Weblate (Portuguese)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2023-01-11 16:54:38 +01:00
Nifou
c916eeb9d7 Translated using Weblate (French)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2023-01-11 16:54:38 +01:00
Zoe
8ee85a4007 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nb_NO/
2023-01-11 16:54:37 +01:00
binwiederhier
36c0be1097 Upgrade smtp library, but not tests 2023-01-04 09:31:32 -05:00
136 changed files with 9347 additions and 15543 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +1,2 @@
github: [binwiederhier] github: [binwiederhier]
liberapay: ntfy

View File

@@ -8,7 +8,7 @@ jobs:
name: Install Go name: Install Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: '1.18.x' go-version: '1.19.x'
- -
name: Install node name: Install node
uses: actions/setup-node@v2 uses: actions/setup-node@v2

View File

@@ -11,7 +11,7 @@ jobs:
name: Install Go name: Install Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: '1.18.x' go-version: '1.19.x'
- -
name: Install node name: Install node
uses: actions/setup-node@v2 uses: actions/setup-node@v2

View File

@@ -8,7 +8,7 @@ jobs:
name: Install Go name: Install Go
uses: actions/setup-go@v2 uses: actions/setup-go@v2
with: with:
go-version: '1.18.x' go-version: '1.19.x'
- -
name: Install node name: Install node
uses: actions/setup-node@v2 uses: actions/setup-node@v2

View File

@@ -88,7 +88,6 @@ build-deps-ubuntu:
curl \ curl \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabi \ gcc-arm-linux-gnueabi \
upx \
jq jq
which pip3 || sudo apt install -y python3-pip which pip3 || sudo apt install -y python3-pip
@@ -201,7 +200,6 @@ cli-deps-static-sites:
touch server/docs/index.html server/site/app.html touch server/docs/index.html server/site/app.html
cli-deps-all: cli-deps-all:
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
go install github.com/goreleaser/goreleaser@latest go install github.com/goreleaser/goreleaser@latest
cli-deps-gcc-armv6-armv7: cli-deps-gcc-armv6-armv7:
@@ -231,14 +229,17 @@ cli-build-results:
check: test fmt-check vet lint staticcheck check: test fmt-check vet lint staticcheck
test: .PHONY test: .PHONY
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
testv: .PHONY
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
race: .PHONY race: .PHONY
go test -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
coverage: coverage:
mkdir -p build/coverage mkdir -p build/coverage
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
go tool cover -func build/coverage/coverage.txt go tool cover -func build/coverage/coverage.txt
coverage-html: coverage-html:

View File

@@ -61,9 +61,9 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
</a> </a>
## Sponsors ## Sponsors
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier). I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
appreciated. A big fat **Thank You** to the folks already sponsoring ntfy: account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a> <a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a> <a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
@@ -110,11 +110,18 @@ appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
<a href="https://github.com/biopsin"><img src="https://github.com/biopsin.png" width="40px" /></a> <a href="https://github.com/biopsin"><img src="https://github.com/biopsin.png" width="40px" /></a>
<a href="https://github.com/thebino"><img src="https://github.com/thebino.png" width="40px" /></a> <a href="https://github.com/thebino"><img src="https://github.com/thebino.png" width="40px" /></a>
<a href="https://github.com/sky4055"><img src="https://github.com/sky4055.png" width="40px" /></a> <a href="https://github.com/sky4055"><img src="https://github.com/sky4055.png" width="40px" /></a>
<a href="https://github.com/julianlam"><img src="https://github.com/julianlam.png" width="40px" /></a>
<a href="https://github.com/andreapx"><img src="https://github.com/andreapx.png" width="40px" /></a>
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free, I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
and [DigitalOcean](https://www.digitalocean.com/) for supporting the project: and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
<a href="https://www.digitalocean.com/"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a> <a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
## Code of Conduct ## Code of Conduct
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.

View File

@@ -4,11 +4,18 @@ import (
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/client" "heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/test" "heckel.io/ntfy/test"
"os"
"testing" "testing"
"time" "time"
) )
func TestMain(m *testing.M) {
log.SetLevel(log.ErrorLevel)
os.Exit(m.Run())
}
func TestClient_Publish_Subscribe(t *testing.T) { func TestClient_Publish_Subscribe(t *testing.T) {
s, port := test.StartServer(t) s, port := test.StartServer(t)
defer test.StopServer(t, s, port) defer test.StopServer(t, s, port)

View File

@@ -87,6 +87,11 @@ func WithBasicAuth(user, pass string) PublishOption {
return WithHeader("Authorization", util.BasicAuth(user, pass)) return WithHeader("Authorization", util.BasicAuth(user, pass))
} }
// WithBearerAuth adds the Authorization header for Bearer auth to the request
func WithBearerAuth(token string) PublishOption {
return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token))
}
// WithNoCache instructs the server not to cache the message server-side // WithNoCache instructs the server not to cache the message server-side
func WithNoCache() PublishOption { func WithNoCache() PublishOption {
return WithHeader("X-Cache", "no") return WithHeader("X-Cache", "no")

View File

@@ -19,7 +19,7 @@ const (
) )
var flagsAccess = append( var flagsAccess = append(
flagsUser, append([]cli.Flag{}, flagsUser...),
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"}, &cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
) )
@@ -189,7 +189,11 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
if err != nil { if err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", u.Name, u.Role) tier := "none"
if u.Tier != nil {
tier = u.Tier.Name
}
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
if u.Role == user.RoleAdmin { if u.Role == user.RoleAdmin {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
} else if len(grants) > 0 { } else if len(grants) > 0 {

View File

@@ -15,7 +15,7 @@ func TestCLI_Access_Show(t *testing.T) {
app, _, _, stderr := newTestApp() app, _, _, stderr := newTestApp()
require.Nil(t, runAccessCommand(app, conf)) require.Nil(t, runAccessCommand(app, conf))
require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)") require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
} }
func TestCLI_Access_Grant_And_Publish(t *testing.T) { func TestCLI_Access_Grant_And_Publish(t *testing.T) {
@@ -32,12 +32,12 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
app, _, _, stderr := newTestApp() app, _, _, stderr := newTestApp()
require.Nil(t, runAccessCommand(app, conf)) require.Nil(t, runAccessCommand(app, conf))
expected := `user phil (admin) expected := `user phil (role: admin, tier: none)
- read-write access to all topics (admin role) - read-write access to all topics (admin role)
user ben (user) user ben (role: user, tier: none)
- read-write access to topic announcements - read-write access to topic announcements
- read-only access to topic sometopic - read-only access to topic sometopic
user * (anonymous) user * (role: anonymous, tier: none)
- read-only access to topic announcements - read-only access to topic announcements
- no access to any (other) topics (server config) - no access to any (other) topics (server config)
` `
@@ -79,7 +79,9 @@ user * (anonymous)
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error { func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
userArgs := []string{ userArgs := []string{
"ntfy", "ntfy",
"--log-level=ERROR",
"access", "access",
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
"--auth-file=" + conf.AuthFile, "--auth-file=" + conf.AuthFile,
"--auth-default-access=" + conf.AuthDefault.String(), "--auth-default-access=" + conf.AuthDefault.String(),
} }

View File

@@ -2,10 +2,12 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/log" "heckel.io/ntfy/log"
"os" "os"
"regexp"
) )
const ( const (
@@ -20,8 +22,15 @@ var flagsDefault = []cli.Flag{
&cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"}, &cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"},
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"}, &cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log format"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-file", Aliases: []string{"log_file"}, EnvVars: []string{"NTFY_LOG_FILE"}, Usage: "set log file, default is STDOUT"}),
} }
var (
logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\s]+)(?:\s*=\s*(\S+))?\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`)
)
// New creates a new CLI application // New creates a new CLI application
func New() *cli.App { func New() *cli.App {
return &cli.App{ return &cli.App{
@@ -40,15 +49,42 @@ func New() *cli.App {
} }
func initLogFunc(c *cli.Context) error { func initLogFunc(c *cli.Context) error {
log.SetLevel(log.ToLevel(c.String("log-level")))
log.SetFormat(log.ToFormat(c.String("log-format")))
if c.Bool("trace") { if c.Bool("trace") {
log.SetLevel(log.TraceLevel) log.SetLevel(log.TraceLevel)
} else if c.Bool("debug") { } else if c.Bool("debug") {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.ToLevel(c.String("log-level")))
} }
if c.Bool("no-log-dates") { if c.Bool("no-log-dates") {
log.DisableDates() log.DisableDates()
} }
if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil {
return err
}
logFile := c.String("log-file")
if logFile != "" {
w, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return err
}
log.SetOutput(w)
}
return nil
}
func applyLogLevelOverrides(rawOverrides []string) error {
for _, override := range rawOverrides {
m := logLevelOverrideRegex.FindStringSubmatch(override)
if len(m) == 4 {
field, value, level := m[1], m[2], m[3]
log.SetLevelOverride(field, value, log.ToLevel(level))
} else if len(m) == 3 {
field, level := m[1], m[2]
log.SetLevelOverride(field, "", log.ToLevel(level)) // Matches any value
} else {
return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override)
}
}
return nil return nil
} }

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"heckel.io/ntfy/client" "heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -13,7 +14,7 @@ import (
// This only contains helpers so far // This only contains helpers so far
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
// log.SetOutput(io.Discard) log.SetLevel(log.ErrorLevel)
os.Exit(m.Run()) os.Exit(m.Run())
} }

View File

@@ -20,7 +20,7 @@ func init() {
} }
var flagsPublish = append( var flagsPublish = append(
flagsDefault, append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"}, &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"}, &cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"}, &cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
@@ -35,6 +35,7 @@ var flagsPublish = append(
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, &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: "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"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"}, &cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"}, &cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"}, &cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
@@ -99,10 +100,18 @@ func execPublish(c *cli.Context) error {
file := c.String("file") file := c.String("file")
email := c.String("email") email := c.String("email")
user := c.String("user") user := c.String("user")
token := c.String("token")
noCache := c.Bool("no-cache") noCache := c.Bool("no-cache")
noFirebase := c.Bool("no-firebase") noFirebase := c.Bool("no-firebase")
quiet := c.Bool("quiet") quiet := c.Bool("quiet")
pid := c.Int("wait-pid") pid := c.Int("wait-pid")
// Checks
if user != "" && token != "" {
return errors.New("cannot set both --user and --token")
}
// Do the things
topic, message, command, err := parseTopicMessageCommand(c) topic, message, command, err := parseTopicMessageCommand(c)
if err != nil { if err != nil {
return err return err
@@ -144,6 +153,9 @@ func execPublish(c *cli.Context) error {
if noFirebase { if noFirebase {
options = append(options, client.WithNoFirebase()) options = append(options, client.WithNoFirebase())
} }
if token != "" {
options = append(options, client.WithBearerAuth(token))
}
if user != "" { if user != "" {
var pass string var pass string
parts := strings.SplitN(user, ":", 2) parts := strings.SplitN(user, ":", 2)

View File

@@ -8,20 +8,27 @@ import (
"os" "os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
) )
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
testMessage := util.RandomString(10) testMessage := util.RandomString(10)
app, _, _, _ := newTestApp() app, _, _, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
time.Sleep(3 * time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
app2, _, stdout, _ := newTestApp() _, err := util.Retry(func() (*int, error) {
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"})) app2, _, stdout, _ := newTestApp()
require.Contains(t, stdout.String(), testMessage) if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil {
return nil, err
}
if !strings.Contains(stdout.String(), testMessage) {
return nil, fmt.Errorf("test message %s not found in topic", testMessage)
}
return util.Int(1), nil
}, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
require.Nil(t, err)
} }
func TestCLI_Publish_Subscribe_Poll(t *testing.T) { func TestCLI_Publish_Subscribe_Poll(t *testing.T) {

View File

@@ -34,7 +34,7 @@ const (
) )
var flagsServe = append( var flagsServe = append(
flagsDefault, append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
@@ -58,6 +58,7 @@ var flagsServe = append(
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
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-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-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
@@ -77,6 +78,7 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
@@ -131,6 +133,7 @@ func execServe(c *cli.Context) error {
attachmentExpiryDuration := c.Duration("attachment-expiry-duration") attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
keepaliveInterval := c.Duration("keepalive-interval") keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-interval") managerInterval := c.Duration("manager-interval")
disallowedTopics := c.StringSlice("disallowed-topics")
webRoot := c.String("web-root") webRoot := c.String("web-root")
enableSignup := c.Bool("enable-signup") enableSignup := c.Bool("enable-signup")
enableLogin := c.Bool("enable-login") enableLogin := c.Bool("enable-login")
@@ -150,6 +153,7 @@ func execServe(c *cli.Context) error {
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",") visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
behindProxy := c.Bool("behind-proxy") behindProxy := c.Bool("behind-proxy")
@@ -249,8 +253,12 @@ func execServe(c *cli.Context) error {
stripe.Key = stripeSecretKey stripe.Key = stripeSecretKey
} }
// Add default forbidden topics
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
// Run server // Run server
conf := server.NewConfig() conf := server.NewConfig()
conf.File = config
conf.BaseURL = baseURL conf.BaseURL = baseURL
conf.ListenHTTP = listenHTTP conf.ListenHTTP = listenHTTP
conf.ListenHTTPS = listenHTTPS conf.ListenHTTPS = listenHTTPS
@@ -273,6 +281,7 @@ func execServe(c *cli.Context) error {
conf.AttachmentExpiryDuration = attachmentExpiryDuration conf.AttachmentExpiryDuration = attachmentExpiryDuration
conf.KeepaliveInterval = keepaliveInterval conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval conf.ManagerInterval = managerInterval
conf.DisallowedTopics = disallowedTopics
conf.WebRootIsApp = webRootIsApp conf.WebRootIsApp = webRootIsApp
conf.UpstreamBaseURL = upstreamBaseURL conf.UpstreamBaseURL = upstreamBaseURL
conf.SMTPSenderAddr = smtpSenderAddr conf.SMTPSenderAddr = smtpSenderAddr
@@ -285,10 +294,11 @@ func execServe(c *cli.Context) error {
conf.TotalTopicLimit = totalTopicLimit conf.TotalTopicLimit = totalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit) conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.BehindProxy = behindProxy conf.BehindProxy = behindProxy
@@ -306,9 +316,9 @@ func execServe(c *cli.Context) error {
// Run server // Run server
s, err := server.New(conf) s, err := server.New(conf)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err.Error())
} else if err := s.Run(); err != nil { } else if err := s.Run(); err != nil {
log.Fatal(err) log.Fatal(err.Error())
} }
log.Info("Exiting.") log.Info("Exiting.")
return nil return nil
@@ -335,7 +345,9 @@ func sigHandlerConfigReload(config string) {
log.Warn("Hot reload failed: %s", err.Error()) log.Warn("Hot reload failed: %s", err.Error())
continue continue
} }
reloadLogLevel(inputSource) if err := reloadLogLevel(inputSource); err != nil {
log.Warn("Reloading log level failed: %s", err.Error())
}
} }
} }
@@ -364,13 +376,24 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
return return
} }
func reloadLogLevel(inputSource altsrc.InputSourceContext) { func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
newLevelStr, err := inputSource.String("log-level") newLevelStr, err := inputSource.String("log-level")
if err != nil { if err != nil {
log.Warn("Cannot load log level: %s", err.Error()) return fmt.Errorf("cannot load log level: %s", err.Error())
return
} }
newLevel := log.ToLevel(newLevelStr) overrides, err := inputSource.StringSlice("log-level-overrides")
log.SetLevel(newLevel) if err != nil {
log.Info("Log level is %s", newLevel.String()) return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
}
log.ResetLevelOverrides()
if err := applyLogLevelOverrides(overrides); err != nil {
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
}
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
} }

View File

@@ -26,7 +26,7 @@ const (
) )
var flagsSubscribe = append( var flagsSubscribe = append(
flagsDefault, append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"}, &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"}, &cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},

337
cmd/tier.go Normal file
View File

@@ -0,0 +1,337 @@
//go:build !noserver
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"time"
)
func init() {
commands = append(commands, cmdTier)
}
const (
defaultMessageLimit = 5000
defaultMessageExpiryDuration = 12 * time.Hour
defaultEmailLimit = 20
defaultReservationLimit = 3
defaultAttachmentFileSizeLimit = "15M"
defaultAttachmentTotalSizeLimit = "100M"
defaultAttachmentExpiryDuration = 6 * time.Hour
defaultAttachmentBandwidthLimit = "1G"
)
var (
flagsTier = append([]cli.Flag{}, flagsUser...)
)
var cmdTier = &cli.Command{
Name: "tier",
Usage: "Manage/show tiers",
UsageText: "ntfy tier [list|add|change|remove] ...",
Flags: flagsTier,
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
Category: categoryServer,
Subcommands: []*cli.Command{
{
Name: "add",
Aliases: []string{"a"},
Usage: "Adds a new tier",
UsageText: "ntfy tier add [OPTIONS] CODE",
Action: execTierAdd,
Flags: []cli.Flag{
&cli.StringFlag{Name: "name", Usage: "tier name"},
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
&cli.DurationFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
&cli.DurationFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"},
},
Description: `Add a new tier to the ntfy user database.
Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or
make it possible for users to reserve topics.
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples:
ntfy tier add pro # Add tier with code "pro", using the defaults
ntfy tier add \ # Add a tier with custom limits
--name="Pro" \
--message-limit=10000 \
--message-expiry-duration=24h \
--email-limit=50 \
--reservation-limit=10 \
--attachment-file-size-limit=100M \
--attachment-total-size-limit=1G \
--attachment-expiry-duration=12h \
--attachment-bandwidth-limit=5G \
pro
`,
},
{
Name: "change",
Aliases: []string{"ch"},
Usage: "Change a tier",
UsageText: "ntfy tier change [OPTIONS] CODE",
Action: execTierChange,
Flags: []cli.Flag{
&cli.StringFlag{Name: "name", Usage: "tier name"},
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
&cli.DurationFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
&cli.DurationFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
},
Description: `Updates a tier to change the limits.
After updating a tier, you may have to restart the ntfy server to apply them
to all visitors.
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples:
ntfy tier change --name="Pro" pro # Update the name of an existing tier
ntfy tier change \ # Update multiple limits and fields
--message-expiry-duration=24h \
--stripe-price-id=price_1234 \
pro
`,
},
{
Name: "remove",
Aliases: []string{"del", "rm"},
Usage: "Removes a tier",
UsageText: "ntfy tier remove CODE",
Action: execTierDel,
Description: `Remove a tier from the ntfy user database.
You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier"
to remove or switch their tier first.
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Example:
ntfy tier del pro
`,
},
{
Name: "list",
Aliases: []string{"l"},
Usage: "Shows a list of tiers",
Action: execTierList,
Description: `Shows a list of all configured tiers.
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
`,
},
},
Description: `Manage tiers of the ntfy server.
The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used
to grant users higher limits, such as daily message limits, attachment size, or make it
possible for users to reserve topics.
This is a server-only command. It directly manages the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples:
ntfy tier add pro # Add tier with code "pro", using the defaults
ntfy tier change --name="Pro" pro # Update the name of an existing tier
ntfy tier del pro # Delete an existing tier
`,
}
func execTierAdd(c *cli.Context) error {
code := c.Args().Get(0)
if code == "" {
return errors.New("tier code expected, type 'ntfy tier add --help' for help")
} else if !user.AllowedTier(code) {
return errors.New("tier code must consist only of numbers and letters")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
if tier, _ := manager.Tier(code); tier != nil {
if c.Bool("ignore-exists") {
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
return nil
}
return fmt.Errorf("tier %s already exists", code)
}
name := c.String("name")
if name == "" {
name = code
}
attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit"))
if err != nil {
return err
}
attachmentTotalSizeLimit, err := util.ParseSize(c.String("attachment-total-size-limit"))
if err != nil {
return err
}
attachmentBandwidthLimit, err := util.ParseSize(c.String("attachment-bandwidth-limit"))
if err != nil {
return err
}
tier := &user.Tier{
ID: "", // Generated
Code: code,
Name: name,
MessageLimit: c.Int64("message-limit"),
MessageExpiryDuration: c.Duration("message-expiry-duration"),
EmailLimit: c.Int64("email-limit"),
ReservationLimit: c.Int64("reservation-limit"),
AttachmentFileSizeLimit: attachmentFileSizeLimit,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"),
AttachmentBandwidthLimit: attachmentBandwidthLimit,
StripePriceID: c.String("stripe-price-id"),
}
if err := manager.AddTier(tier); err != nil {
return err
}
tier, err = manager.Tier(code)
if err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
printTier(c, tier)
return nil
}
func execTierChange(c *cli.Context) error {
code := c.Args().Get(0)
if code == "" {
return errors.New("tier code expected, type 'ntfy tier change --help' for help")
} else if !user.AllowedTier(code) {
return errors.New("tier code must consist only of numbers and letters")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
tier, err := manager.Tier(code)
if err == user.ErrTierNotFound {
return fmt.Errorf("tier %s does not exist", code)
} else if err != nil {
return err
}
if c.IsSet("name") {
tier.Name = c.String("name")
}
if c.IsSet("message-limit") {
tier.MessageLimit = c.Int64("message-limit")
}
if c.IsSet("message-expiry-duration") {
tier.MessageExpiryDuration = c.Duration("message-expiry-duration")
}
if c.IsSet("email-limit") {
tier.EmailLimit = c.Int64("email-limit")
}
if c.IsSet("reservation-limit") {
tier.ReservationLimit = c.Int64("reservation-limit")
}
if c.IsSet("attachment-file-size-limit") {
tier.AttachmentFileSizeLimit, err = util.ParseSize(c.String("attachment-file-size-limit"))
if err != nil {
return err
}
}
if c.IsSet("attachment-total-size-limit") {
tier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String("attachment-total-size-limit"))
if err != nil {
return err
}
}
if c.IsSet("attachment-expiry-duration") {
tier.AttachmentExpiryDuration = c.Duration("attachment-expiry-duration")
}
if c.IsSet("attachment-bandwidth-limit") {
tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit"))
if err != nil {
return err
}
}
if c.IsSet("stripe-price-id") {
tier.StripePriceID = c.String("stripe-price-id")
}
if err := manager.UpdateTier(tier); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
printTier(c, tier)
return nil
}
func execTierDel(c *cli.Context) error {
code := c.Args().Get(0)
if code == "" {
return errors.New("tier code expected, type 'ntfy tier del --help' for help")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
if _, err := manager.Tier(code); err == user.ErrTierNotFound {
return fmt.Errorf("tier %s does not exist", code)
}
if err := manager.RemoveTier(code); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
return nil
}
func execTierList(c *cli.Context) error {
manager, err := createUserManager(c)
if err != nil {
return err
}
tiers, err := manager.Tiers()
if err != nil {
return err
}
for _, tier := range tiers {
printTier(c, tier)
}
return nil
}
func printTier(c *cli.Context, tier *user.Tier) {
stripePriceID := tier.StripePriceID
if stripePriceID == "" {
stripePriceID = "(none)"
}
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
fmt.Fprintf(c.App.ErrWriter, "- Stripe price: %s\n", stripePriceID)
}

66
cmd/tier_test.go Normal file
View File

@@ -0,0 +1,66 @@
package cmd
import (
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
"heckel.io/ntfy/test"
"testing"
)
func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, _, _, stderr := newTestApp()
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
err := runTierCommand(app, conf, "add", "pro")
require.NotNil(t, err)
require.Equal(t, "tier pro already exists", err.Error())
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "list"))
require.Contains(t, stderr.String(), "tier pro (id: ti_")
require.Contains(t, stderr.String(), "- Name: Pro")
require.Contains(t, stderr.String(), "- Message limit: 1234")
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "change",
"--message-limit=999",
"--message-expiry-duration=99h",
"--email-limit=91",
"--reservation-limit=98",
"--attachment-file-size-limit=100m",
"--attachment-expiry-duration=7h",
"--attachment-total-size-limit=10G",
"--attachment-bandwidth-limit=100G",
"--stripe-price-id=price_991",
"pro",
))
require.Contains(t, stderr.String(), "- Message limit: 999")
require.Contains(t, stderr.String(), "- Message expiry duration: 99h")
require.Contains(t, stderr.String(), "- Email limit: 91")
require.Contains(t, stderr.String(), "- Reservation limit: 98")
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
require.Contains(t, stderr.String(), "- Attachment expiry duration: 7h")
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
require.Contains(t, stderr.String(), "- Stripe price: price_991")
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
require.Contains(t, stderr.String(), "tier pro removed")
}
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
userArgs := []string{
"ntfy",
"--log-level=ERROR",
"tier",
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
"--auth-file=" + conf.AuthFile,
"--auth-default-access=" + conf.AuthDefault.String(),
}
return app.Run(append(userArgs, args...))
}

210
cmd/token.go Normal file
View File

@@ -0,0 +1,210 @@
//go:build !noserver
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"net/netip"
"time"
)
func init() {
commands = append(commands, cmdToken)
}
var flagsToken = append([]cli.Flag{}, flagsUser...)
var cmdToken = &cli.Command{
Name: "token",
Usage: "Create, list or delete user tokens",
UsageText: "ntfy token [list|add|remove] ...",
Flags: flagsToken,
Before: initConfigFileInputSourceFunc("config", flagsToken, initLogFunc),
Category: categoryServer,
Subcommands: []*cli.Command{
{
Name: "add",
Aliases: []string{"a"},
Usage: "Create a new token",
UsageText: "ntfy token add [--expires=<duration>] [--label=..] USERNAME",
Action: execTokenAdd,
Flags: []cli.Flag{
&cli.StringFlag{Name: "expires", Aliases: []string{"e"}, Value: "", Usage: "token expires after"},
&cli.StringFlag{Name: "label", Aliases: []string{"l"}, Value: "", Usage: "token label"},
},
Description: `Create a new user access token.
User access tokens can be used to publish, subscribe, or perform any other user-specific tasks.
Tokens have full access, and can perform any task a user can do. They are meant to be used to
avoid spreading the password to various places.
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples:
ntfy token add phil # Create token for user phil which never expires
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday
ntfy token add -l backups phil # Create token for user phil with label "backups"`,
},
{
Name: "remove",
Aliases: []string{"del", "rm"},
Usage: "Removes a token",
UsageText: "ntfy token remove USERNAME TOKEN",
Action: execTokenDel,
Description: `Remove a token from the ntfy user database.
Example:
ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN`,
},
{
Name: "list",
Aliases: []string{"l"},
Usage: "Shows a list of tokens",
Action: execTokenList,
Description: `Shows a list of all tokens.
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.`,
},
},
Description: `Manage access tokens for individual users.
User access tokens can be used to publish, subscribe, or perform any other user-specific tasks.
Tokens have full access, and can perform any task a user can do. They are meant to be used to
avoid spreading the password to various places.
This is a server-only command. It directly manages the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples:
ntfy token list # Shows list of tokens for all users
ntfy token list phil # Shows list of tokens for user phil
ntfy token add phil # Create token for user phil which never expires
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
ntfy token remove phil tk_th2srHVlxr... # Delete token`,
}
func execTokenAdd(c *cli.Context) error {
username := c.Args().Get(0)
expiresStr := c.String("expires")
label := c.String("label")
if username == "" {
return errors.New("username expected, type 'ntfy token add --help' for help")
} else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed")
}
expires := time.Unix(0, 0)
if expiresStr != "" {
var err error
expires, err = util.ParseFutureTime(expiresStr, time.Now())
if err != nil {
return err
}
}
manager, err := createUserManager(c)
if err != nil {
return err
}
u, err := manager.User(username)
if err == user.ErrUserNotFound {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
}
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified())
if err != nil {
return err
}
if expires.Unix() == 0 {
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name)
} else {
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
}
return nil
}
func execTokenDel(c *cli.Context) error {
username, token := c.Args().Get(0), c.Args().Get(1)
if username == "" || token == "" {
return errors.New("username and token expected, type 'ntfy token remove --help' for help")
} else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
u, err := manager.User(username)
if err == user.ErrUserNotFound {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
}
if err := manager.RemoveToken(u.ID, token); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username)
return nil
}
func execTokenList(c *cli.Context) error {
username := c.Args().Get(0)
if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
var users []*user.User
if username != "" {
u, err := manager.User(username)
if err == user.ErrUserNotFound {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
}
users = append(users, u)
} else {
users, err = manager.Users()
if err != nil {
return err
}
}
usersWithTokens := 0
for _, u := range users {
tokens, err := manager.Tokens(u.ID)
if err != nil {
return err
} else if len(tokens) == 0 && username != "" {
fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username)
return nil
} else if len(tokens) == 0 {
continue
}
usersWithTokens++
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name)
for _, t := range tokens {
var label, expires string
if t.Label != "" {
label = fmt.Sprintf(" (%s)", t.Label)
}
if t.Expires.Unix() == 0 {
expires = "never expires"
} else {
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
}
fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822))
}
}
if usersWithTokens == 0 {
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n")
}
return nil
}

50
cmd/token_test.go Normal file
View File

@@ -0,0 +1,50 @@
package cmd
import (
"fmt"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
"heckel.io/ntfy/test"
"regexp"
"testing"
)
func TestCLI_Token_AddListRemove(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user")
app, _, _, stderr = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String())
app, _, _, stderr = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String())
re := regexp.MustCompile(`tk_\w+`)
token := re.FindString(stderr.String())
app, _, _, stderr = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String())
app, _, _, stderr = newTestApp()
require.Nil(t, runTokenCommand(app, conf, "list"))
require.Equal(t, "no users with tokens\n", stderr.String())
}
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
userArgs := []string{
"ntfy",
"--log-level=ERROR",
"token",
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
"--auth-file=" + conf.AuthFile,
}
return app.Run(append(userArgs, args...))
}

View File

@@ -16,8 +16,7 @@ import (
) )
const ( const (
tierReset = "-" tierReset = "-"
createdByCLI = "cli"
) )
func init() { func init() {
@@ -25,7 +24,7 @@ func init() {
} }
var flagsUser = append( var flagsUser = append(
flagsDefault, append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, 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-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: "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"}),
@@ -47,6 +46,7 @@ var cmdUser = &cli.Command{
Action: execUserAdd, Action: execUserAdd,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"}, &cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"},
}, },
Description: `Add a new user to the ntfy user database. Description: `Add a new user to the ntfy user database.
@@ -140,22 +140,22 @@ Example:
Action: execUserList, Action: execUserList,
Description: `Shows a list of all configured users, including the everyone ('*') user. Description: `Shows a list of all configured users, including the everyone ('*') user.
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
This command is an alias to calling 'ntfy access' (display access control list). This command is an alias to calling 'ntfy access' (display access control list).
This is a server-only command. It directly reads from user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
`, `,
}, },
}, },
Description: `Manage users of the ntfy server. Description: `Manage users of the ntfy server.
The command allows you to add/remove/change users in the ntfy user database, as well as change
passwords or roles.
This is a server-only command. It directly manages the user.db as defined in the server config This is a server-only command. It directly manages the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
to the related command 'ntfy access'. to the related command 'ntfy access'.
The command allows you to add/remove/change users in the ntfy user database, as well as change
passwords or roles.
Examples: Examples:
ntfy user list # Shows list of users (alias: 'ntfy access') ntfy user list # Shows list of users (alias: 'ntfy access')
ntfy user add phil # Add regular user phil ntfy user add phil # Add regular user phil
@@ -177,7 +177,7 @@ func execUserAdd(c *cli.Context) error {
password := os.Getenv("NTFY_PASSWORD") password := os.Getenv("NTFY_PASSWORD")
if username == "" { if username == "" {
return errors.New("username expected, type 'ntfy user add --help' for help") return errors.New("username expected, type 'ntfy user add --help' for help")
} else if username == userEveryone { } else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed") return errors.New("username not allowed")
} else if !user.AllowedRole(role) { } else if !user.AllowedRole(role) {
return errors.New("role must be either 'user' or 'admin'") return errors.New("role must be either 'user' or 'admin'")
@@ -187,6 +187,10 @@ func execUserAdd(c *cli.Context) error {
return err return err
} }
if user, _ := manager.User(username); user != nil { if user, _ := manager.User(username); user != nil {
if c.Bool("ignore-exists") {
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
return nil
}
return fmt.Errorf("user %s already exists", username) return fmt.Errorf("user %s already exists", username)
} }
if password == "" { if password == "" {
@@ -197,7 +201,7 @@ func execUserAdd(c *cli.Context) error {
password = p password = p
} }
if err := manager.AddUser(username, password, role, createdByCLI); err != nil { if err := manager.AddUser(username, password, role); err != nil {
return err return err
} }
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role) fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
@@ -208,7 +212,7 @@ func execUserDel(c *cli.Context) error {
username := c.Args().Get(0) username := c.Args().Get(0)
if username == "" { if username == "" {
return errors.New("username expected, type 'ntfy user del --help' for help") return errors.New("username expected, type 'ntfy user del --help' for help")
} else if username == userEveryone { } else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed") return errors.New("username not allowed")
} }
manager, err := createUserManager(c) manager, err := createUserManager(c)
@@ -230,7 +234,7 @@ func execUserChangePass(c *cli.Context) error {
password := os.Getenv("NTFY_PASSWORD") password := os.Getenv("NTFY_PASSWORD")
if username == "" { if username == "" {
return errors.New("username expected, type 'ntfy user change-pass --help' for help") return errors.New("username expected, type 'ntfy user change-pass --help' for help")
} else if username == userEveryone { } else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed") return errors.New("username not allowed")
} }
manager, err := createUserManager(c) manager, err := createUserManager(c)
@@ -258,7 +262,7 @@ func execUserChangeRole(c *cli.Context) error {
role := user.Role(c.Args().Get(1)) role := user.Role(c.Args().Get(1))
if username == "" || !user.AllowedRole(role) { if username == "" || !user.AllowedRole(role) {
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help") return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
} else if username == userEveryone { } else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed") return errors.New("username not allowed")
} }
manager, err := createUserManager(c) manager, err := createUserManager(c)
@@ -282,7 +286,7 @@ func execUserChangeTier(c *cli.Context) error {
return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help") return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help")
} else if !user.AllowedTier(tier) && tier != tierReset { } else if !user.AllowedTier(tier) && tier != tierReset {
return errors.New("invalid tier, must be tier code, or - to reset") return errors.New("invalid tier, must be tier code, or - to reset")
} else if username == userEveryone { } else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed") return errors.New("username not allowed")
} }
manager, err := createUserManager(c) manager, err := createUserManager(c)
@@ -331,7 +335,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
if err != nil { 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'") return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} }
return user.NewManager(authFile, authStartupQueries, authDefault) return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval)
} }
func readPasswordAndConfirm(c *cli.Context) (string, error) { func readPasswordAndConfirm(c *cli.Context) (string, error) {

View File

@@ -6,6 +6,7 @@ import (
"heckel.io/ntfy/server" "heckel.io/ntfy/server"
"heckel.io/ntfy/test" "heckel.io/ntfy/test"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -113,7 +114,10 @@ func TestCLI_User_Delete(t *testing.T) {
} }
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
conf = server.NewConfig() conf = server.NewConfig()
conf.File = configFile
conf.AuthFile = filepath.Join(t.TempDir(), "user.db") conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthDefault = user.PermissionDenyAll conf.AuthDefault = user.PermissionDenyAll
s, port = test.StartServerWithConfig(t, conf) s, port = test.StartServerWithConfig(t, conf)
@@ -123,7 +127,9 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error { func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
userArgs := []string{ userArgs := []string{
"ntfy", "ntfy",
"--log-level=ERROR",
"user", "user",
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
"--auth-file=" + conf.AuthFile, "--auth-file=" + conf.AuthFile,
"--auth-default-access=" + conf.AuthDefault.String(), "--auth-default-access=" + conf.AuthDefault.String(),
} }

View File

@@ -161,6 +161,7 @@ ntfy user add --role=admin phil # Add admin user phil
ntfy user del phil # Delete user phil ntfy user del phil # Delete user phil
ntfy user change-pass phil # Change password for user phil ntfy user change-pass phil # Change password for user phil
ntfy user change-role phil admin # Make user phil an admin ntfy user change-role phil admin # Make user phil an admin
ntfy user change-tier phil pro # Change phil's tier to "pro"
``` ```
### Access control list (ACL) ### Access control list (ACL)
@@ -222,6 +223,39 @@ User `ben` has three topic-specific entries. He can read, but not write to topic
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
### Access tokens
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
want to use a dedicated token to publish from your backup host, and one from your home automation system.
!!! info
As of today, access tokens grant users **full access to the user account**. Aside from changing the password,
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
but not yet implemented.
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
automatically (or never expire). Each user can have up to 20 tokens (hardcoded).
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
```
ntfy token list # Shows list of tokens for all users
ntfy token list phil # Shows list of tokens for user phil
ntfy token add phil # Create token for user phil which never expires
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
ntfy token remove phil tk_th2sxr... # Delete token
```
**Creating an access token:**
```
$ ntfy token add --expires=30d --label="backups" phil
$ ntfy token list
user phil
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
```
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
### Example: Private instance ### Example: Private instance
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
@@ -504,7 +538,7 @@ or the root domain:
proxy_send_timeout 3m; proxy_send_timeout 3m;
proxy_read_timeout 3m; proxy_read_timeout 3m;
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml client_max_body_size 0; # Stream request body to backend
} }
} }
@@ -540,7 +574,7 @@ or the root domain:
proxy_send_timeout 3m; proxy_send_timeout 3m;
proxy_read_timeout 3m; proxy_read_timeout 3m;
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml client_max_body_size 0; # Stream request body to backend
} }
} }
``` ```
@@ -571,7 +605,7 @@ or the root domain:
proxy_send_timeout 3m; proxy_send_timeout 3m;
proxy_read_timeout 3m; proxy_read_timeout 3m;
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml client_max_body_size 0; # Stream request body to backend
} }
} }
@@ -603,7 +637,7 @@ or the root domain:
proxy_send_timeout 3m; proxy_send_timeout 3m;
proxy_read_timeout 3m; proxy_read_timeout 3m;
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml client_max_body_size 0; # Stream request body to backend
} }
} }
``` ```
@@ -754,6 +788,69 @@ Note that the self-hosted server literally sends the message `New message` for e
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all), may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
it'll show `New message` as a popup. it'll show `New message` as a popup.
## Tiers
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
tiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way
to switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)).
By default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file.
Once a user is associated with a tier, some limits are overridden based on the tier.
The `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers.
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
```
ntfy tier add pro # Add tier with code "pro", using the defaults
ntfy tier change --name="Pro" pro # Update the name of an existing tier
ntfy tier del starter # Delete an existing tier
ntfy user change-tier phil pro # Switch user "phil" to tier "pro"
```
**Creating a tier (full example):**
```
ntfy tier add \
--name="Pro" \
--message-limit=10000 \
--message-expiry-duration=24h \
--email-limit=50 \
--reservation-limit=10 \
--attachment-file-size-limit=100M \
--attachment-total-size-limit=1G \
--attachment-expiry-duration=12h \
--attachment-bandwidth-limit=5G \
--stripe-price-id=price_123456 \
pro
```
## Payments
ntfy supports paid [tiers](#tiers) via [Stripe](https://stripe.com/) as a payment provider. If payments are enabled,
users can register, login and switch plans in the web app. The web app will behave slightly differently if payments
are enabled (e.g. showing an upgrade banner, or "ntfy Pro" tags).
!!! info
The ntfy payments integration is very tailored to ntfy.sh and Stripe. I do not intend to support arbitrary use
cases.
To enable payments, sign up with [Stripe](https://stripe.com/), set the `stripe-secret-key` and `stripe-webhook-key`
config options:
* `stripe-secret-key` is the key used for the Stripe API communication. Setting this values
enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys).
* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe.
Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks).
In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks)
for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points
to `https://ntfy.example.com/v1/account/billing/webhook`.
Here's an example:
``` yaml
stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG"
stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
```
## Rate limiting ## Rate limiting
!!! info !!! info
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
@@ -788,7 +885,15 @@ request every 5s (defined by `visitor-request-limit-replenish`)
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s. * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate * `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list. limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
### Message limits
By default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits).
For instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests
to publish messages, then that is the daily message limit.
To limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number
of messages a visitor can send in a day. This counter is reset every day at midnight (UTC).
### Attachment limits ### Attachment limits
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
per-visitor limits: per-visitor limits:
@@ -962,18 +1067,57 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
maxretry = 10 maxretry = 10
``` ```
## Debugging/tracing ## Logging & debugging
By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.
ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
log level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded
by calling `kill -HUP $pid` or `systemctl reload ntfy`.
The following config options define the logging behavior:
* `log-format` defines the output format, can be `text` (default) or `json`
* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr.
* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`.
Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes.
* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful
for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
This is an array of strings in the format:
- `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace`
- `field -> level` to match any value, e.g. `time_taken_ms -> debug`
**Logging config (good for production use):**
``` yaml
log-level: info
log-format: json
log-file: /var/log/ntfy.log
```
**Temporary debugging:**
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level` If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message to `debug` or `trace`. The `debug` setting will output information about each published message, but not the message
contents. The `TRACE` setting will also print the message contents. contents. The `trace` setting will also print the message contents.
Alternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`),
a username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what
they are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy).
Here's an example that will output only `info` log events, except when they match either of the defined overrides:
``` yaml
log-level: info
log-level-overrides:
- "tag=manager -> trace"
- "visitor_ip=1.2.3.4 -> debug"
- "time_taken_ms -> debug"
```
!!! warning !!! warning
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise, The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a
you're going to run out of disk space pretty quickly. performance penalty. Only use it for temporary debugging.
You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file. You can also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`. editing the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd),
If successful, you'll see something like this: or by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this:
``` ```
$ ntfy serve $ ntfy serve
@@ -1029,14 +1173,15 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | | `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | | `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | | `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | | `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | | `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | | `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) | | `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
| `enable-signup` | `NTFY_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in 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_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | | `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | | `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 | | `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
@@ -1057,58 +1202,71 @@ CATEGORY:
DESCRIPTION: DESCRIPTION:
Run the ntfy server and listen for incoming requests Run the ntfy server and listen for incoming requests
The command will load the configuration from /etc/ntfy/server.yml. Config options can The command will load the configuration from /etc/ntfy/server.yml. Config options can
be overridden using the command line options. be overridden using the command line options.
Examples: Examples:
ntfy serve # Starts server in the foreground (on port 80) ntfy serve # Starts server in the foreground (on port 80)
ntfy serve --listen-http :8080 # Starts server with alternate port ntfy serve --listen-http :8080 # Starts server with alternate port
OPTIONS: OPTIONS:
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] --debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] --trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] --no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] --log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] --log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE] --log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] --log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] --base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] --listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE] --listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT] --listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES] --listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] --key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] --cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG] --firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] --cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] --cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL] --cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE] --cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] --cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] --auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX] --auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL] --auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] --attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES] --attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] --attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] --keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] --manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] --disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] --web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] --enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE] --enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL] --enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] --upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] --smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] --smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] --smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] --smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] --smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] --smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] --smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT] --global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
--help, -h show help (default: false)
``` ```

View File

@@ -92,7 +92,6 @@ sudo apt install \
gcc-arm-linux-gnueabi \ gcc-arm-linux-gnueabi \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
python3-pip \ python3-pip \
upx \
git git
``` ```
@@ -328,7 +327,76 @@ To build your own version with Firebase, you must:
``` ```
## iOS app ## iOS app
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios). Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are
strictly based off of my development on this app. There may be other versions of macOS / XCode that work.
### Requirements
1. macOS Monterey or later
1. XCode 13.2+
1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator)
1. Firebase account
1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license)
### Apple setup
!!! info !!! info
I haven't had time to move the build instructions here. Please check out the repository instead. Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required
for these changes to take effect in the iOS app.
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
1. Select "Apple Push Notifications service (APNs)"
1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)
1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page
1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer.
!!! warning
If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs.
If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered
instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application
sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours,
days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly
recommended.
### Firebase setup
1. If you haven't already, create a Google / Firebase account
1. Visit the [Firebase console](https://console.firebase.google.com)
1. Create a new Firebase project:
1. Enter a project name
1. Disable Google Analytics (currently iOS app does not support analytics)
1. On the "Project settings" page, add an iOS app
1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value)
1. Register the app
1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)
1. Generate a new service account private key for the ntfy server
1. Go to "Project settings" > "Service accounts"
1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server
### ntfy server
Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these
steps:
1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder
1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`
1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key
1. Install go: `brew install go`
1. In the ntfy repository, run `make cli-darwin-server`.
### XCode setup
1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) to install the
`firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging
1. Similarly, install the SQLite.swift package dependency in XCode
1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators
### PLIST config
To have instant notifications/better notification delivery when using firebase, you will need to add the
`GoogleService-Info.plist` file to your project. Here's how to do that:
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"
After that, you should be all set!

View File

@@ -413,7 +413,8 @@ alerting:
## Jellyseerr/Overseerr webhook ## Jellyseerr/Overseerr webhook
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL. JSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click.
And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic.
``` json ``` json
{ {

View File

@@ -47,6 +47,11 @@ or you use *instant delivery* (Android only), the app has to maintain a constant
about 0-1% of battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty about 0-1% of battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
decent now. decent now.
## Paid plans? I thought it was open source?
All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you
can (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher
limits.
## What is instant delivery? ## What is instant delivery?
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the [Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
server and listens for incoming notifications. This consumes additional battery (see above), server and listens for incoming notifications. This consumes additional battery (see above),

View File

@@ -26,37 +26,37 @@ deb/rpm packages.
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_x86_64.tar.gz
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz tar zxvf ntfy_2.0.0_linux_x86_64.tar.gz
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.0.0_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_x86_64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.tar.gz
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz tar zxvf ntfy_2.0.0_linux_armv6.tar.gz
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.0.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv6/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.tar.gz
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz tar zxvf ntfy_2.0.0_linux_armv7.tar.gz
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.0.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv7/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.tar.gz
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz tar zxvf ntfy_2.0.0_linux_arm64.tar.gz
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.0.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
@@ -106,7 +106,7 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -114,7 +114,7 @@ Manually installing the .deb file:
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -122,7 +122,7 @@ Manually installing the .deb file:
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -130,7 +130,7 @@ Manually installing the .deb file:
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -140,28 +140,28 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "arm64" === "arm64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
@@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS ## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. 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/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz), To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). 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 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). `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash ```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz > ntfy_2.0.0_macOS_all.tar.gz
tar zxvf ntfy_1.30.1_macOS_all.tar.gz tar zxvf ntfy_2.0.0_macOS_all.tar.gz
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.0.0_macOS_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy mkdir ~/Library/Application\ Support/ntfy
cp ntfy_1.30.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml cp ntfy_2.0.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help ntfy --help
``` ```
@@ -212,7 +212,7 @@ ntfy --help
## Windows ## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. 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/v1.30.1/ntfy_1.30.1_windows_x86_64.zip), To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_windows_x86_64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
@@ -287,7 +287,7 @@ services:
restart: unless-stopped restart: unless-stopped
``` ```
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid. 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.
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately. Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
``` ```

View File

@@ -32,6 +32,7 @@ and uptime of third party servers, so use of each server is **at your own discre
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard - [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool - [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media - [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
- [Scrt.link](https://scrt.link/) - Share a secret - [Scrt.link](https://scrt.link/) - Share a secret
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python - [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
@@ -71,6 +72,7 @@ and uptime of third party servers, so use of each server is **at your own discre
## Projects + scripts ## Projects + scripts
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust) - [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh) - [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell) - [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell) - [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
@@ -107,9 +109,15 @@ and uptime of third party servers, so use of each server is **at your own discre
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java) - [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python) - [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python) - [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
## Blog + forum posts ## Blog + forum posts
- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023
- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023
- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023 - [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022 - [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022 - [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
@@ -127,6 +135,7 @@ and uptime of third party servers, so use of each server is **at your own discre
- [Ntfy.sh Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022 - [Ntfy.sh Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022
- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022 - [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022
- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022 - [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022
- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/2022
- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022 - [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022
- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022 - [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022
- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022 - [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022

View File

@@ -2591,23 +2591,22 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
<figcaption>Publishing a message via e-mail</figcaption> <figcaption>Publishing a message via e-mail</figcaption>
</figure> </figure>
## Advanced features ## Authentication
### Authentication
Depending on whether the server is configured to support [access control](config.md#access-control), some topics Depending on whether the server is configured to support [access control](config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them. may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
To publish/subscribe to protected topics, you can: To publish/subscribe to protected topics, you can:
* Use [basic auth](#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk` * Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
* or use the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw` * Use [access tokens](#bearer-auth) via Bearer/Basic auth, e.g. `Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`
* or use either with the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
!!! warning !!! warning
Base64 only encodes username and password. It **is not encrypting it**. For your self-hosted server, When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your
**be sure to use HTTPS to avoid eavesdropping** and exposing your password. self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password.
#### Basic auth ### Username & password
Here's an example using [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication), with a user `testuser` The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication).
and password `fakepassword`: Here's an example with a user `testuser` and password `fakepassword`:
=== "Command line (curl)" === "Command line (curl)"
``` ```
@@ -2701,7 +2700,172 @@ The following command will generate the appropriate value for you on *nix system
echo "Basic $(echo -n 'testuser:fakepassword' | base64)" echo "Basic $(echo -n 'testuser:fakepassword' | base64)"
``` ```
#### Query param ### Access tokens
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
want to use a dedicated token to publish from your backup host, and one from your home automation system.
You can create access tokens using the `ntfy token` command, or in the web app in the "Account" section (when logged in).
See [access tokens](config.md#access-tokens) for details.
Once an access token is created, you can use it to authenticate against the ntfy server, e.g. when you publish or
subscribe to topics. Here's an example using [Bearer auth](https://swagger.io/docs/specification/authentication/bearer-authentication/),
with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`:
=== "Command line (curl)"
```
curl \
-H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
'content' => 'Look ma, with auth'
]
]));
```
Alternatively, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) to send the
access token. When sending an empty username, the basic auth password is treated by the ntfy server as an
access token. This is primarily useful to make `curl` calls easier, e.g. `curl -u:tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ...`:
=== "Command line (curl)"
```
curl \
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy',
'content' => 'Look ma, with auth'
]
]));
```
### Query param
Here's an example using the `auth` query parameter: Here's an example using the `auth` query parameter:
=== "Command line (curl)" === "Command line (curl)"
@@ -2786,6 +2950,8 @@ The following command will generate the appropriate value for you on *nix system
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '=' echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
``` ```
## Advanced features
### Message caching ### Message caching
!!! info !!! info
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
@@ -2984,9 +3150,6 @@ that you can use to try out what [authentication and access control](#authentica
|------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------| |------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------|
| [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such | | [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such |
| [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage | | [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage |
| [mytopic-rw](https://ntfy.sh/mytopic-rw) | `testuser` (password: `testuser`) | Read-write for `testuser`, no access for anyone else | Test topic |
| [mytopic-ro](https://ntfy.sh/mytopic-ro) | `testuser` (password: `testuser`) | Read-only for `testuser`, no access for anyone else | Test topic |
| [mytopic-wo](https://ntfy.sh/mytopic-wo) | `testuser` (password: `testuser`) | Write-only for `testuser`, no access for anyone else | Test topic |
## Limitations ## Limitations
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings

View File

@@ -2,7 +2,75 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) 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). and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
## ntfy server v1.31.0 (UNRELEASED) ## ntfy server v2.0.0
Released February 16, 2023
This is the biggest ntfy server release I've ever done 🥳 . Lots of new and exciting features.
**Brand-new features:**
* **User signup/login & account sync**: If enabled, users can now register to create a user account, and then login to
the web app. Once logged in, topic subscriptions and user settings are stored server-side in the user account (as
opposed to only in the browser storage). So far, this is implemented only in the web app only. Once it's in the Android/iOS
app, you can easily keep your account in sync. Relevant [config options](config.md#config-options) are `enable-signup` and
`enable-login`.
<div id="account-screenshots" class="screenshots">
<a href="../../static/img/web-signup.png"><img src="../../static/img/web-signup.png"/></a>
<a href="../../static/img/web-account.png"><img src="../../static/img/web-account.png"/></a>
</div>
* **Topic reservations** 🎉: If enabled, users can now **reserve topics and restrict access to other users**.
Once this is fully rolled out, you may reserve `ntfy.sh/philbackups` and define access so that only you can publish/subscribe
to the topic. Reservations let you claim ownership of a topic, and you can define access permissions for others as
`deny-all` (only you have full access), `read-only` (you can publish/subscribe, others can subscribe), `write-only` (you
can publish/subscribe, others can publish), `read-write` (everyone can publish/subscribe, but you remain the owner).
Topic reservations can be [configured](config.md#config-options) in the web app if `enable-reservations` is enabled, and
only if the user has a [tier](config.md#tiers) that supports reservations.
<div id="reserve-screenshots" class="screenshots">
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
</div>
* **Access tokens:** It is now possible to create user access tokens for a user account. Access tokens are useful
to avoid having to paste your password to various applications or scripts. For instance, you may want to use a
dedicated token to publish from your backup host, and one from your home automation system. Tokens can be configured
in the web app, or via the `ntfy token` command. See [creating tokens](config.md#access-tokens),
and [publishing using tokens](publish.md#access-tokens).
<div id="token-screenshots" class="screenshots">
<a href="../../static/img/web-token-create.png"><img src="../../static/img/web-token-create.png"/></a>
<a href="../../static/img/web-token-list.png"><img src="../../static/img/web-token-list.png"/></a>
</div>
* **Structured logging:** I've redone a lot of the logging to make it more structured, and to make it easier to debug and
troubleshoot. Logs can now be written to a file, and as JSON (if configured). Each log event carries context fields
that you can filter and search on using tools like `jq`. On top of that, you can override the log level if certain fields
match. For instance, you can say `user_name=phil -> debug` to log everything related to a certain user with debug level.
See [logging & debugging](config.md#logging-debugging).
* **Tiers:** You can now define and associate usage tiers to users. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. You could, for instance, have
a tier `Standard` that allows 500 messages/day, 15 MB attachments and 5 allowed topic reservations, and another
tier `Friends & Family` with much higher limits. For ntfy.sh, I'll mostly use these tiers to facilitate paid plans (see below).
Tiers can be configured via the `ntfy tier ...` command. See [tiers](config.md#tiers).
* **Paid tiers:** Starting very soon, I will be offering paid tiers for ntfy.sh on top of the free service. You'll be
able to subscribe to tiers with higher rate limits (more daily messages, bigger attachments) and topic reservations.
Paid tiers are facilitated by integrating [Stripe](https://stripe.com) as a payment provider. See [payments](config.md#payments)
for details.
**ntfy is forever open source!**
Yes, I will be offering some paid plans. But you don't need to panic! I won't be taking any features away, and everything
will remain forever open source, so you can self-host if you like. Similar to the donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
and [Liberapay](https://en.liberapay.com/ntfy/), paid plans will help pay for the service and keep me motivated to keep
going. It'll only make ntfy better.
**Other tickets:**
* User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522))
* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting)
## ntfy server v1.31.0
Released February 14, 2023
This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting
things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal
of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a
breaking-change upgrade, which required some work to get working again.
**Features:** **Features:**
@@ -13,12 +81,24 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus)) * Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting) * Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))
**Documentation:** **Documentation:**
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90)) * Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD)) * Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan)) * Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))
* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))
**Additional languages:**
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
**Special thanks:**
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, and @xenrox.
## ntfy server v1.30.1 ## ntfy server v1.30.1
Released December 23, 2022 🎅 Released December 23, 2022 🎅

View File

@@ -8,9 +8,6 @@
width: unset !important; width: unset !important;
} }
header {
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
}
.md-header__topic:first-child { .md-header__topic:first-child {
font-weight: 400; font-weight: 400;
@@ -34,12 +31,30 @@ figure img, figure video {
border-radius: 7px; border-radius: 7px;
} }
body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video { header {
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
}
body[data-md-color-scheme="default"] header {
filter: drop-shadow(0 5px 10px #ccc);
}
body[data-md-color-scheme="slate"] header {
filter: drop-shadow(0 5px 10px #333);
}
body[data-md-color-scheme="default"] figure img,
body[data-md-color-scheme="default"] figure video,
body[data-md-color-scheme="default"] .screenshots img,
body[data-md-color-scheme="default"] .screenshots video {
filter: drop-shadow(3px 3px 3px #ccc); filter: drop-shadow(3px 3px 3px #ccc);
} }
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video { body[data-md-color-scheme="slate"] figure img,
filter: drop-shadow(3px 3px 3px #1a1313); body[data-md-color-scheme="slate"] figure video,
body[data-md-color-scheme="slate"] .screenshots img,
body[data-md-color-scheme="slate"] .screenshots video {
filter: drop-shadow(3px 3px 3px #353744);
} }
figure video { figure video {

BIN
docs/static/img/web-account.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
docs/static/img/web-reserve-topic.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
docs/static/img/web-signup.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
docs/static/img/web-token-create.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
docs/static/img/web-token-list.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -18,3 +18,10 @@ is to pin the tab so that it's always open, but sort of out of the way:
![pinned](../static/img/web-pin.png){ width=500 } ![pinned](../static/img/web-pin.png){ width=500 }
<figcaption>Pin web app to move it out of the way</figcaption> <figcaption>Pin web app to move it out of the way</figcaption>
</figure> </figure>
If topic reservations are enabled, you can claim ownership over topics and define access to it:
<div id="reserve-screenshots" class="screenshots">
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
</div>

36
go.mod
View File

@@ -4,22 +4,22 @@ go 1.18
require ( require (
cloud.google.com/go/firestore v1.9.0 // indirect cloud.google.com/go/firestore v1.9.0 // indirect
cloud.google.com/go/storage v1.28.1 // indirect cloud.google.com/go/storage v1.29.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect github.com/BurntSushi/toml v1.2.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/emersion/go-smtp v0.15.0 github.com/emersion/go-smtp v0.16.0
github.com/gabriel-vasile/mimetype v1.4.1 github.com/gabriel-vasile/mimetype v1.4.1
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.16 github.com/mattn/go-sqlite3 v1.14.16
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
github.com/stretchr/testify v1.8.1 github.com/stretchr/testify v1.8.1
github.com/urfave/cli/v2 v2.23.7 github.com/urfave/cli/v2 v2.24.4
golang.org/x/crypto v0.4.0 golang.org/x/crypto v0.6.0
golang.org/x/oauth2 v0.3.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect
golang.org/x/sync v0.1.0 golang.org/x/sync v0.1.0
golang.org/x/term v0.3.0 golang.org/x/term v0.5.0
golang.org/x/time v0.3.0 golang.org/x/time v0.3.0
google.golang.org/api v0.105.0 google.golang.org/api v0.110.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
@@ -27,15 +27,15 @@ require github.com/pkg/errors v0.9.1 // indirect
require ( require (
firebase.google.com/go/v4 v4.10.0 firebase.google.com/go/v4 v4.10.0
github.com/stripe/stripe-go/v74 v74.5.0 github.com/stripe/stripe-go/v74 v74.7.0
) )
require ( require (
cloud.google.com/go v0.107.0 // indirect cloud.google.com/go v0.110.0 // indirect
cloud.google.com/go/compute v1.14.0 // indirect cloud.google.com/go/compute v1.18.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v0.9.0 // indirect cloud.google.com/go/iam v0.10.0 // indirect
cloud.google.com/go/longrunning v0.3.0 // indirect cloud.google.com/go/longrunning v0.4.1 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
@@ -45,21 +45,21 @@ require (
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.4.0 // indirect golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.3.0 // indirect golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.2 // indirect google.golang.org/appengine/v2 v2.0.2 // indirect
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
google.golang.org/grpc v1.51.0 // indirect google.golang.org/grpc v1.53.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

74
go.sum
View File

@@ -1,18 +1,18 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.107.0 h1:qkj22L7bgkl6vIeZDlOY2po43Mx/TIa2Wsa7VR+PEww= cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0= cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
cloud.google.com/go/iam v0.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs= cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI=
cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM= cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs= cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI= cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4= firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A= firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
@@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -71,12 +71,12 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ= github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg= github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ= github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@@ -101,18 +101,18 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4= github.com/stripe/stripe-go/v74 v74.7.0 h1:KHlyslQj9YOv62b1sycQ31LFj7KlqR+seHsSowAWrjc=
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw= github.com/stripe/stripe-go/v74 v74.7.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY= github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -127,11 +127,11 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8= golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -144,17 +144,17 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -165,8 +165,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8= google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI= google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@@ -176,15 +176,15 @@ google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4Ho
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef h1:uQ2vjV/sHTsWSqdKeLqmwitzgvjMl7o4IdtHwUDXSJY= google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U= google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

231
log/event.go Normal file
View File

@@ -0,0 +1,231 @@
package log
import (
"encoding/json"
"fmt"
"log"
"os"
"sort"
"strings"
"time"
)
const (
tagField = "tag"
errorField = "error"
timeTakenField = "time_taken_ms"
exitCodeField = "exit_code"
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
)
// Event represents a single log event
type Event struct {
Timestamp string `json:"time"`
Level Level `json:"level"`
Message string `json:"message"`
time time.Time
contexters []Contexter
fields Context
}
// newEvent creates a new log event
//
// We delay allocations and processing for efficiency, because most log events
// are never actually rendered, so we don't format the time, or allocate a fields map.
func newEvent() *Event {
return &Event{
time: time.Now(),
}
}
// Fatal logs the event as FATAL, and exits the program with exit code 1
func (e *Event) Fatal(message string, v ...any) {
e.Field(exitCodeField, 1).maybeLog(FatalLevel, message, v...)
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
os.Exit(1)
}
// Error logs the event with log level error
func (e *Event) Error(message string, v ...any) {
e.maybeLog(ErrorLevel, message, v...)
}
// Warn logs the event with log level warn
func (e *Event) Warn(message string, v ...any) {
e.maybeLog(WarnLevel, message, v...)
}
// Info logs the event with log level info
func (e *Event) Info(message string, v ...any) {
e.maybeLog(InfoLevel, message, v...)
}
// Debug logs the event with log level debug
func (e *Event) Debug(message string, v ...any) {
e.maybeLog(DebugLevel, message, v...)
}
// Trace logs the event with log level trace
func (e *Event) Trace(message string, v ...any) {
e.maybeLog(TraceLevel, message, v...)
}
// Tag adds a "tag" field to the log event
func (e *Event) Tag(tag string) *Event {
return e.Field(tagField, tag)
}
// Time sets the time field
func (e *Event) Time(t time.Time) *Event {
e.time = t
return e
}
// Timing runs f and records the time if took to execute it in "time_taken_ms"
func (e *Event) Timing(f func()) *Event {
start := time.Now()
f()
return e.Field(timeTakenField, time.Since(start).Milliseconds())
}
// Err adds an "error" field to the log event
func (e *Event) Err(err error) *Event {
if err == nil {
return e
} else if c, ok := err.(Contexter); ok {
return e.With(c)
}
return e.Field(errorField, err.Error())
}
// Field adds a custom field and value to the log event
func (e *Event) Field(key string, value any) *Event {
if e.fields == nil {
e.fields = make(Context)
}
e.fields[key] = value
return e
}
// Fields adds a map of fields to the log event
func (e *Event) Fields(fields Context) *Event {
if e.fields == nil {
e.fields = make(Context)
}
for k, v := range fields {
e.fields[k] = v
}
return e
}
// With adds the fields of the given Contexter structs to the log event by calling their With method
func (e *Event) With(contexts ...Contexter) *Event {
if e.contexters == nil {
e.contexters = contexts
} else {
e.contexters = append(e.contexters, contexts...)
}
return e
}
// maybeLog logs the event to the defined output. The event is only logged, if
// either the global log level is >= l, or if the log level in one of the overrides matches
// the level.
//
// If no overrides are defined (default), the Contexter array is not applied unless the event
// is actually logged. If overrides are defined, then Contexters have to be applied in any case
// to determine if they match. This is super complicated, but required for efficiency.
func (e *Event) maybeLog(l Level, message string, v ...any) {
appliedContexters := e.maybeApplyContexters()
if !e.shouldLog(l) {
return
}
e.Message = fmt.Sprintf(message, v...)
e.Level = l
e.Timestamp = e.time.Format(timestampFormat)
if !appliedContexters {
e.applyContexters()
}
if CurrentFormat() == JSONFormat {
log.Println(e.JSON())
} else {
log.Println(e.String())
}
}
// Loggable returns true if the given log level is lower or equal to the current log level
func (e *Event) Loggable(l Level) bool {
return e.globalLevelWithOverride() <= l
}
// IsTrace returns true if the current log level is TraceLevel
func (e *Event) IsTrace() bool {
return e.Loggable(TraceLevel)
}
// IsDebug returns true if the current log level is DebugLevel or below
func (e *Event) IsDebug() bool {
return e.Loggable(DebugLevel)
}
// JSON returns the event as a JSON representation
func (e *Event) JSON() string {
b, _ := json.Marshal(e)
s := string(b)
if len(e.fields) > 0 {
b, _ := json.Marshal(e.fields)
s = fmt.Sprintf("{%s,%s}", s[1:len(s)-1], string(b[1:len(b)-1]))
}
return s
}
// String returns the event as a string
func (e *Event) String() string {
if len(e.fields) == 0 {
return fmt.Sprintf("%s %s", e.Level.String(), e.Message)
}
fields := make([]string, 0)
for k, v := range e.fields {
fields = append(fields, fmt.Sprintf("%s=%v", k, v))
}
sort.Strings(fields)
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
}
func (e *Event) shouldLog(l Level) bool {
return e.globalLevelWithOverride() <= l
}
func (e *Event) globalLevelWithOverride() Level {
mu.RLock()
l, ov := level, overrides
mu.RUnlock()
if e.fields == nil {
return l
}
for field, override := range ov {
value, exists := e.fields[field]
if exists {
if override.value == "" || override.value == value || override.value == fmt.Sprintf("%v", value) {
return override.level
}
}
}
return l
}
func (e *Event) maybeApplyContexters() bool {
mu.RLock()
hasOverrides := len(overrides) > 0
mu.RUnlock()
if hasOverrides {
e.applyContexters()
}
return hasOverrides // = applied
}
func (e *Event) applyContexters() {
for _, c := range e.contexters {
e.Fields(c.Context())
}
}

View File

@@ -1,78 +1,92 @@
package log package log
import ( import (
"io"
"log" "log"
"strings" "os"
"sync" "sync"
"time"
) )
// Level is a well-known log level, as defined below // Defaults for package level variables
type Level int var (
DefaultLevel = InfoLevel
// Well known log levels DefaultFormat = TextFormat
const ( DefaultOutput = os.Stderr
TraceLevel Level = iota
DebugLevel
InfoLevel
WarnLevel
ErrorLevel
) )
func (l Level) String() string {
switch l {
case TraceLevel:
return "TRACE"
case DebugLevel:
return "DEBUG"
case InfoLevel:
return "INFO"
case WarnLevel:
return "WARN"
case ErrorLevel:
return "ERROR"
}
return "unknown"
}
var ( var (
level = InfoLevel level = DefaultLevel
mu = &sync.Mutex{} format = DefaultFormat
overrides = make(map[string]*levelOverride)
output io.Writer = DefaultOutput
mu = &sync.RWMutex{}
) )
// Trace prints the given message, if the current log level is TRACE // Fatal prints the given message, and exits the program
func Trace(message string, v ...any) { func Fatal(message string, v ...any) {
logIf(TraceLevel, message, v...) newEvent().Fatal(message, v...)
}
// Debug prints the given message, if the current log level is DEBUG or lower
func Debug(message string, v ...any) {
logIf(DebugLevel, message, v...)
}
// Info prints the given message, if the current log level is INFO or lower
func Info(message string, v ...any) {
logIf(InfoLevel, message, v...)
}
// Warn prints the given message, if the current log level is WARN or lower
func Warn(message string, v ...any) {
logIf(WarnLevel, message, v...)
} }
// Error prints the given message, if the current log level is ERROR or lower // Error prints the given message, if the current log level is ERROR or lower
func Error(message string, v ...any) { func Error(message string, v ...any) {
logIf(ErrorLevel, message, v...) newEvent().Error(message, v...)
} }
// Fatal prints the given message, and exits the program // Warn prints the given message, if the current log level is WARN or lower
func Fatal(v ...any) { func Warn(message string, v ...any) {
log.Fatalln(v...) newEvent().Warn(message, v...)
}
// Info prints the given message, if the current log level is INFO or lower
func Info(message string, v ...any) {
newEvent().Info(message, v...)
}
// Debug prints the given message, if the current log level is DEBUG or lower
func Debug(message string, v ...any) {
newEvent().Debug(message, v...)
}
// Trace prints the given message, if the current log level is TRACE
func Trace(message string, v ...any) {
newEvent().Trace(message, v...)
}
// With creates a new log event and adds the fields of the given Contexter structs
func With(contexts ...Contexter) *Event {
return newEvent().With(contexts...)
}
// Field creates a new log event and adds a custom field and value to it
func Field(key string, value any) *Event {
return newEvent().Field(key, value)
}
// Fields creates a new log event and adds a map of fields to it
func Fields(fields Context) *Event {
return newEvent().Fields(fields)
}
// Tag creates a new log event and adds a "tag" field to it
func Tag(tag string) *Event {
return newEvent().Tag(tag)
}
// Time creates a new log event and sets the time field
func Time(time time.Time) *Event {
return newEvent().Time(time)
}
// Timing runs f and records the time if took to execute it in "time_taken_ms"
func Timing(f func()) *Event {
return newEvent().Timing(f)
} }
// CurrentLevel returns the current log level // CurrentLevel returns the current log level
func CurrentLevel() Level { func CurrentLevel() Level {
mu.Lock() mu.RLock()
defer mu.Unlock() defer mu.RUnlock()
return level return level
} }
@@ -83,30 +97,70 @@ func SetLevel(newLevel Level) {
level = newLevel level = newLevel
} }
// SetLevelOverride adds a log override for the given field
func SetLevelOverride(field string, value string, level Level) {
mu.Lock()
defer mu.Unlock()
overrides[field] = &levelOverride{value: value, level: level}
}
// ResetLevelOverrides removes all log level overrides
func ResetLevelOverrides() {
mu.Lock()
defer mu.Unlock()
overrides = make(map[string]*levelOverride)
}
// CurrentFormat returns the current log format
func CurrentFormat() Format {
mu.RLock()
defer mu.RUnlock()
return format
}
// SetFormat sets a new log format
func SetFormat(newFormat Format) {
mu.Lock()
defer mu.Unlock()
format = newFormat
if newFormat == JSONFormat {
DisableDates()
}
}
// SetOutput sets the log output writer
func SetOutput(w io.Writer) {
mu.Lock()
defer mu.Unlock()
log.SetOutput(w)
output = w
}
// File returns the log file, if any, or an empty string otherwise
func File() string {
mu.RLock()
defer mu.RUnlock()
if f, ok := output.(*os.File); ok {
return f.Name()
}
return ""
}
// IsFile returns true if the output is a non-default file
func IsFile() bool {
mu.RLock()
defer mu.RUnlock()
if _, ok := output.(*os.File); ok && output != DefaultOutput {
return true
}
return false
}
// DisableDates disables the date/time prefix // DisableDates disables the date/time prefix
func DisableDates() { func DisableDates() {
log.SetFlags(0) log.SetFlags(0)
} }
// ToLevel converts a string to a Level. It returns InfoLevel if the string
// does not match any known log levels.
func ToLevel(s string) Level {
switch strings.ToUpper(s) {
case "TRACE":
return TraceLevel
case "DEBUG":
return DebugLevel
case "INFO":
return InfoLevel
case "WARN", "WARNING":
return WarnLevel
case "ERROR":
return ErrorLevel
default:
return InfoLevel
}
}
// Loggable returns true if the given log level is lower or equal to the current log level // Loggable returns true if the given log level is lower or equal to the current log level
func Loggable(l Level) bool { func Loggable(l Level) bool {
return CurrentLevel() <= l return CurrentLevel() <= l
@@ -121,9 +175,3 @@ func IsTrace() bool {
func IsDebug() bool { func IsDebug() bool {
return Loggable(DebugLevel) return Loggable(DebugLevel)
} }
func logIf(l Level, message string, v ...any) {
if CurrentLevel() <= l {
log.Printf(l.String()+" "+message, v...)
}
}

210
log/log_test.go Normal file
View File

@@ -0,0 +1,210 @@
package log
import (
"bytes"
"encoding/json"
"github.com/stretchr/testify/require"
"os"
"testing"
"time"
)
func TestMain(m *testing.M) {
exitCode := m.Run()
resetState()
SetLevel(ErrorLevel) // For other modules!
os.Exit(exitCode)
}
func TestLog_TagContextFieldFields(t *testing.T) {
t.Cleanup(resetState)
v := &fakeVisitor{
UserID: "u_abc",
IP: "1.2.3.4",
}
err := &fakeError{
Code: 123,
Message: "some error",
}
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
SetLevelOverride("tag", "stripe", DebugLevel)
SetLevelOverride("number", "5", DebugLevel)
Tag("mytag").
Field("field2", 123).
Field("field1", "value1").
Time(time.Unix(123, 999000000).UTC()).
Info("hi there %s", "phil")
Tag("not-stripe").
Debug("this message will not appear")
With(v).
Fields(Context{
"stripe_customer_id": "acct_123",
"stripe_subscription_id": "sub_123",
}).
Tag("stripe").
Err(err).
Time(time.Unix(456, 123000000).UTC()).
Debug("Subscription status %s", "active")
Field("number", 5).
Time(time.Unix(777, 001000000).UTC()).
Debug("The number 5 is an int, but the level override is a string")
expected := `{"time":"1970-01-01T00:02:03.999Z","level":"INFO","message":"hi there phil","field1":"value1","field2":123,"tag":"mytag"}
{"time":"1970-01-01T00:07:36.123Z","level":"DEBUG","message":"Subscription status active","error":"some error","error_code":123,"stripe_customer_id":"acct_123","stripe_subscription_id":"sub_123","tag":"stripe","user_id":"u_abc","visitor_ip":"1.2.3.4"}
{"time":"1970-01-01T00:12:57Z","level":"DEBUG","message":"The number 5 is an int, but the level override is a string","number":5}
`
require.Equal(t, expected, out.String())
}
func TestLog_NoAllocIfNotPrinted(t *testing.T) {
t.Cleanup(resetState)
v := &fakeVisitor{
UserID: "u_abc",
IP: "1.2.3.4",
}
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
// Do not log, do not call contexters (because global level is INFO)
v.contextCalled = false
ev := With(v)
ev.Debug("some message")
require.False(t, v.contextCalled)
require.Equal(t, "", ev.Timestamp)
require.Equal(t, Level(0), ev.Level)
require.Equal(t, "", ev.Message)
require.Nil(t, ev.fields)
// Logged because info level, contexters called
v.contextCalled = false
ev = With(v).Time(time.Unix(1111, 0).UTC())
ev.Info("some message")
require.True(t, v.contextCalled)
require.NotNil(t, ev.fields)
require.Equal(t, "1.2.3.4", ev.fields["visitor_ip"])
// Not logged, but contexters called, because overrides exist
SetLevel(DebugLevel)
SetLevelOverride("tag", "overridetag", TraceLevel)
v.contextCalled = false
ev = Tag("sometag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC())
ev.Trace("some debug message")
require.True(t, v.contextCalled) // If there are overrides, we must call the context to determine the filter fields
require.Equal(t, "", ev.Timestamp)
require.Equal(t, Level(0), ev.Level)
require.Equal(t, "", ev.Message)
require.Equal(t, 4, len(ev.fields))
require.Equal(t, "value", ev.fields["field"])
require.Equal(t, "sometag", ev.fields["tag"])
// Logged because of override tag, and contexters called
v.contextCalled = false
ev = Tag("overridetag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC())
ev.Trace("some trace message")
require.True(t, v.contextCalled)
require.Equal(t, "1970-01-01T00:02:03Z", ev.Timestamp)
require.Equal(t, TraceLevel, ev.Level)
require.Equal(t, "some trace message", ev.Message)
// Logged because of field override, and contexters called
ResetLevelOverrides()
SetLevelOverride("visitor_ip", "1.2.3.4", TraceLevel)
v.contextCalled = false
ev = With(v).Time(time.Unix(124, 0).UTC())
ev.Trace("some trace message with override")
require.True(t, v.contextCalled)
require.Equal(t, "1970-01-01T00:02:04Z", ev.Timestamp)
require.Equal(t, TraceLevel, ev.Level)
require.Equal(t, "some trace message with override", ev.Message)
expected := `{"time":"1970-01-01T00:18:31Z","level":"INFO","message":"some message","user_id":"u_abc","visitor_ip":"1.2.3.4"}
{"time":"1970-01-01T00:02:03Z","level":"TRACE","message":"some trace message","field":"value","tag":"overridetag","user_id":"u_abc","visitor_ip":"1.2.3.4"}
{"time":"1970-01-01T00:02:04Z","level":"TRACE","message":"some trace message with override","user_id":"u_abc","visitor_ip":"1.2.3.4"}
`
require.Equal(t, expected, out.String())
}
func TestLog_Timing(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
Timing(func() { time.Sleep(300 * time.Millisecond) }).
Time(time.Unix(12, 0).UTC()).
Info("A thing that takes a while")
var ev struct {
TimeTakenMs int64 `json:"time_taken_ms"`
}
require.Nil(t, json.Unmarshal(out.Bytes(), &ev))
require.True(t, ev.TimeTakenMs >= 300)
require.Contains(t, out.String(), `{"time":"1970-01-01T00:00:12Z","level":"INFO","message":"A thing that takes a while","time_taken_ms":`)
}
func TestLog_LevelOverrideAny(t *testing.T) {
t.Cleanup(resetState)
var out bytes.Buffer
SetOutput(&out)
SetFormat(JSONFormat)
SetLevelOverride("this_one", "", DebugLevel)
SetLevelOverride("time_taken_ms", "", TraceLevel)
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Debug("this is logged")
Time(time.Unix(12, 0).UTC()).Field("not_this", "11").Debug("this is not logged")
Time(time.Unix(13, 0).UTC()).Field("this_too", "11").Info("this is also logged")
Time(time.Unix(14, 0).UTC()).Field("time_taken_ms", 0).Info("this is also logged")
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","this_one":"11"}
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","this_too":"11"}
{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0}
`
require.Equal(t, expected, out.String())
}
type fakeError struct {
Code int
Message string
}
func (e fakeError) Error() string {
return e.Message
}
func (e fakeError) Context() Context {
return Context{
"error": e.Message,
"error_code": e.Code,
}
}
type fakeVisitor struct {
UserID string
IP string
contextCalled bool
}
func (v *fakeVisitor) Context() Context {
v.contextCalled = true
return Context{
"user_id": v.UserID,
"visitor_ip": v.IP,
}
}
func resetState() {
SetLevel(DefaultLevel)
SetFormat(DefaultFormat)
SetOutput(DefaultOutput)
ResetLevelOverrides()
}

108
log/types.go Normal file
View File

@@ -0,0 +1,108 @@
package log
import (
"encoding/json"
"strings"
)
// Level is a well-known log level, as defined below
type Level int
// Well known log levels
const (
TraceLevel Level = iota
DebugLevel
InfoLevel
WarnLevel
ErrorLevel
FatalLevel
)
func (l Level) String() string {
switch l {
case TraceLevel:
return "TRACE"
case DebugLevel:
return "DEBUG"
case InfoLevel:
return "INFO"
case WarnLevel:
return "WARN"
case ErrorLevel:
return "ERROR"
case FatalLevel:
return "FATAL"
}
return "unknown"
}
// MarshalJSON converts a level to a JSON string
func (l Level) MarshalJSON() ([]byte, error) {
return json.Marshal(l.String())
}
// ToLevel converts a string to a Level. It returns InfoLevel if the string
// does not match any known log levels.
func ToLevel(s string) Level {
switch strings.ToUpper(s) {
case "TRACE":
return TraceLevel
case "DEBUG":
return DebugLevel
case "INFO":
return InfoLevel
case "WARN", "WARNING":
return WarnLevel
case "ERROR":
return ErrorLevel
case "FATAL":
return FatalLevel
default:
return InfoLevel
}
}
// Format is a well-known log format
type Format int
// Log formats
const (
TextFormat Format = iota
JSONFormat
)
func (f Format) String() string {
switch f {
case TextFormat:
return "text"
case JSONFormat:
return "json"
}
return "unknown"
}
// ToFormat converts a string to a Format. It returns TextFormat if the string
// does not match any known log formats.
func ToFormat(s string) Format {
switch strings.ToLower(s) {
case "text":
return TextFormat
case "json":
return JSONFormat
default:
return TextFormat
}
}
// Contexter allows structs to export a key-value pairs in the form of a Context
type Contexter interface {
Context() Context
}
// Context represents an object's state in the form of key-value pairs
type Context map[string]any
type levelOverride struct {
value string
level Level
}

View File

@@ -76,7 +76,7 @@ nav:
- "Sending messages": publish.md - "Sending messages": publish.md
- "Subscribing": - "Subscribing":
- "From your phone": subscribe/phone.md - "From your phone": subscribe/phone.md
- "From the Web UI": subscribe/web.md - "From the Web app": subscribe/web.md
- "From the CLI": subscribe/cli.md - "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md - "Using the API": subscribe/api.md
- "Self-hosting": - "Self-hosting":

View File

@@ -19,7 +19,7 @@ const (
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs) DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded" DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
DefaultStripePriceCacheDuration = time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
) )
// Defines all global and per-visitor limits // Defines all global and per-visitor limits
@@ -44,10 +44,13 @@ const (
DefaultVisitorSubscriptionLimit = 30 DefaultVisitorSubscriptionLimit = 30
DefaultVisitorRequestLimitBurst = 60 DefaultVisitorRequestLimitBurst = 60
DefaultVisitorRequestLimitReplenish = 5 * time.Second DefaultVisitorRequestLimitReplenish = 5 * time.Second
DefaultVisitorMessageDailyLimit = 0
DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitBurst = 16
DefaultVisitorEmailLimitReplenish = time.Hour DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorAccountCreateLimitBurst = 3 DefaultVisitorAccountCreationLimitBurst = 3
DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
DefaultVisitorAuthFailureLimitBurst = 10
DefaultVisitorAuthFailureLimitReplenish = time.Minute
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
) )
@@ -55,10 +58,15 @@ const (
var ( var (
// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only) // DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC) DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)
// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be
// extended using the server.yml config. If updated, also update in Android and web app.
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"}
) )
// Config is the main config struct for the application. Use New to instantiate a default config struct. // Config is the main config struct for the application. Use New to instantiate a default config struct.
type Config struct { type Config struct {
File string // Config file, only used for testing
BaseURL string BaseURL string
ListenHTTP string ListenHTTP string
ListenHTTPS string ListenHTTPS string
@@ -75,12 +83,15 @@ type Config struct {
AuthFile string AuthFile string
AuthStartupQueries string AuthStartupQueries string
AuthDefault user.Permission AuthDefault user.Permission
AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration
AttachmentCacheDir string AttachmentCacheDir string
AttachmentTotalSizeLimit int64 AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64 AttachmentFileSizeLimit int64
AttachmentExpiryDuration time.Duration AttachmentExpiryDuration time.Duration
KeepaliveInterval time.Duration KeepaliveInterval time.Duration
ManagerInterval time.Duration ManagerInterval time.Duration
DisallowedTopics []string
WebRootIsApp bool WebRootIsApp bool
DelayedSenderInterval time.Duration DelayedSenderInterval time.Duration
FirebaseKeepaliveInterval time.Duration FirebaseKeepaliveInterval time.Duration
@@ -101,14 +112,17 @@ type Config struct {
TotalAttachmentSizeLimit int64 TotalAttachmentSizeLimit int64
VisitorSubscriptionLimit int VisitorSubscriptionLimit int
VisitorAttachmentTotalSizeLimit int64 VisitorAttachmentTotalSizeLimit int64
VisitorAttachmentDailyBandwidthLimit int VisitorAttachmentDailyBandwidthLimit int64
VisitorRequestLimitBurst int VisitorRequestLimitBurst int
VisitorRequestLimitReplenish time.Duration VisitorRequestLimitReplenish time.Duration
VisitorRequestExemptIPAddrs []netip.Prefix VisitorRequestExemptIPAddrs []netip.Prefix
VisitorMessageDailyLimit int
VisitorEmailLimitBurst int VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration VisitorEmailLimitReplenish time.Duration
VisitorAccountCreateLimitBurst int VisitorAccountCreationLimitBurst int
VisitorAccountCreateLimitReplenish time.Duration VisitorAccountCreationLimitReplenish time.Duration
VisitorAuthFailureLimitBurst int
VisitorAuthFailureLimitReplenish time.Duration
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
BehindProxy bool BehindProxy bool
StripeSecretKey string StripeSecretKey string
@@ -125,6 +139,7 @@ type Config struct {
// NewConfig instantiates a default new server config // NewConfig instantiates a default new server config
func NewConfig() *Config { func NewConfig() *Config {
return &Config{ return &Config{
File: "", // Only used for testing
BaseURL: "", BaseURL: "",
ListenHTTP: DefaultListenHTTP, ListenHTTP: DefaultListenHTTP,
ListenHTTPS: "", ListenHTTPS: "",
@@ -140,13 +155,16 @@ func NewConfig() *Config {
CacheBatchTimeout: 0, CacheBatchTimeout: 0,
AuthFile: "", AuthFile: "",
AuthStartupQueries: "", AuthStartupQueries: "",
AuthDefault: user.NewPermission(true, true), AuthDefault: user.PermissionReadWrite,
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AttachmentCacheDir: "", AttachmentCacheDir: "",
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
KeepaliveInterval: DefaultKeepaliveInterval, KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval, ManagerInterval: DefaultManagerInterval,
DisallowedTopics: DefaultDisallowedTopics,
WebRootIsApp: false, WebRootIsApp: false,
DelayedSenderInterval: DefaultDelayedSenderInterval, DelayedSenderInterval: DefaultDelayedSenderInterval,
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
@@ -171,10 +189,13 @@ func NewConfig() *Config {
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0), VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
VisitorAccountCreateLimitBurst: DefaultVisitorAccountCreateLimitBurst, VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
VisitorAccountCreateLimitReplenish: DefaultVisitorAccountCreateLimitReplenish, VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
VisitorStatsResetTime: DefaultVisitorStatsResetTime, VisitorStatsResetTime: DefaultVisitorStatsResetTime,
BehindProxy: false, BehindProxy: false,
StripeSecretKey: "", StripeSecretKey: "",

View File

@@ -3,6 +3,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"heckel.io/ntfy/log"
"net/http" "net/http"
) )
@@ -23,6 +24,14 @@ func (e errHTTP) JSON() string {
return string(b) return string(b)
} }
func (e errHTTP) Context() log.Context {
return log.Context{
"error": e.Message,
"error_code": e.Code,
"http_status": e.HTTPCode,
}
}
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP { func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
return &errHTTP{ return &errHTTP{
Code: err.Code, Code: err.Code,
@@ -33,6 +42,7 @@ func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
} }
var ( var (
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", ""}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
@@ -42,7 +52,7 @@ var (
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""} errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is disallowed", ""} errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"} errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"} errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
@@ -57,7 +67,7 @@ var (
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""} errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""} errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""} errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
errHTTPBadRequestMakesNoSenseForAdmin = &errHTTP{40026, http.StatusBadRequest, "invalid request: this makes no sense for admins", ""} errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""}
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""} errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""} errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""} errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
@@ -66,6 +76,7 @@ var (
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""} errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""} errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", ""}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""} errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
@@ -73,10 +84,11 @@ var (
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""} errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: too many messages", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}

View File

@@ -44,6 +44,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
if !fileIDRegex.MatchString(id) { if !fileIDRegex.MatchString(id) {
return 0, errInvalidFileID return 0, errInvalidFileID
} }
log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment")
file := filepath.Join(c.dir, id) file := filepath.Join(c.dir, id)
if _, err := os.Stat(file); err == nil { if _, err := os.Stat(file); err == nil {
return 0, errFileExists return 0, errFileExists
@@ -75,10 +76,10 @@ func (c *fileCache) Remove(ids ...string) error {
if !fileIDRegex.MatchString(id) { if !fileIDRegex.MatchString(id) {
return errInvalidFileID return errInvalidFileID
} }
log.Debug("File Cache: Deleting attachment %s", id) log.Tag(tagFileCache).Field("message_id", id).Debug("Deleting attachment")
file := filepath.Join(c.dir, id) file := filepath.Join(c.dir, id)
if err := os.Remove(file); err != nil { if err := os.Remove(file); err != nil {
log.Debug("File Cache: Error deleting attachment %s: %s", id, err.Error()) log.Tag(tagFileCache).Field("message_id", id).Err(err).Debug("Error deleting attachment")
} }
} }
size, err := dirSize(c.dir) size, err := dirSize(c.dir)

118
server/log.go Normal file
View File

@@ -0,0 +1,118 @@
package server
import (
"fmt"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"net/http"
"strings"
"unicode/utf8"
)
// Log tags
const (
tagStartup = "startup"
tagHTTP = "http"
tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagFileCache = "file_cache"
tagMessageCache = "message_cache"
tagStripe = "stripe"
tagAccount = "account"
tagManager = "manager"
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
)
// logr creates a new log event with HTTP request fields
func logr(r *http.Request) *log.Event {
return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten
}
// logv creates a new log event with visitor fields
func logv(v *visitor) *log.Event {
return log.With(v)
}
// logvr creates a new log event with HTTP request and visitor fields
func logvr(v *visitor, r *http.Request) *log.Event {
return logr(r).With(v)
}
// 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 {
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 {
return logv(v).With(m)
}
// logem creates a new log event with email fields
func logem(smtpConn *smtp.Conn) *log.Event {
ev := log.Tag(tagSMTP).Field("smtp_hostname", smtpConn.Hostname())
if smtpConn.Conn() != nil {
ev.Field("smtp_remote_addr", smtpConn.Conn().RemoteAddr().String())
}
return ev
}
func httpContext(r *http.Request) log.Context {
requestURI := r.RequestURI
if requestURI == "" {
requestURI = r.URL.Path
}
return log.Context{
"http_method": r.Method,
"http_path": requestURI,
}
}
func websocketErrorContext(err error) log.Context {
if c, ok := err.(*websocket.CloseError); ok {
return log.Context{
"error": c.Error(),
"error_code": c.Code,
"error_type": "websocket.CloseError",
}
}
return log.Context{
"error": err.Error(),
}
}
func renderHTTPRequest(r *http.Request) string {
peekLimit := 4096
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
for key, values := range r.Header {
for _, value := range values {
lines += fmt.Sprintf("%s: %s\n", key, value)
}
}
lines += "\n"
body, err := util.Peek(r.Body, peekLimit)
if err != nil {
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
} else if utf8.Valid(body.PeekedBytes) {
lines += string(body.PeekedBytes)
if body.LimitReached {
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
}
lines += "\n"
} else {
if body.LimitReached {
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
} else {
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
}
}
r.Body = body // Important: Reset body, so it can be re-read
return strings.TrimSpace(lines)
}

View File

@@ -16,6 +16,7 @@ import (
var ( var (
errUnexpectedMessageType = errors.New("unexpected message type") errUnexpectedMessageType = errors.New("unexpected message type")
errMessageNotFound = errors.New("message not found")
) )
// Messages cache // Messages cache
@@ -50,6 +51,8 @@ const (
CREATE INDEX IF NOT EXISTS idx_time ON messages (time); 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_topic ON messages (topic);
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires); 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 INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
COMMIT; COMMIT;
` `
@@ -60,7 +63,12 @@ const (
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?` deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesSinceTimeQuery = ` selectMessagesByIDQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
FROM messages
WHERE mid = ?
`
selectMessagesSinceTimeQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
FROM messages FROM messages
WHERE topic = ? AND time >= ? AND published = 1 WHERE topic = ? AND time >= ? AND published = 1
@@ -98,8 +106,8 @@ const (
updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?` selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
selectAttachmentsSizeByUserQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
) )
// Schema management queries // Schema management queries
@@ -209,6 +217,8 @@ const (
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
ALTER TABLE messages ADD COLUMN expires 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_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 INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
` `
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?` migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
@@ -363,10 +373,10 @@ func (c *messageCache) addMessages(ms []*message) error {
} }
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
log.Error("Message Cache: Writing %d message(s) failed (took %v)", len(ms), time.Since(start)) log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
return err return err
} }
log.Debug("Message Cache: Wrote %d message(s) in %v", len(ms), time.Since(start)) log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start))
return nil return nil
} }
@@ -448,6 +458,18 @@ func (c *messageCache) MessagesExpired() ([]string, error) {
return ids, nil return ids, nil
} }
func (c *messageCache) Message(id string) (*message, error) {
rows, err := c.db.Query(selectMessagesByIDQuery, id)
if err != nil {
return nil, err
}
if !rows.Next() {
return nil, errMessageNotFound
}
defer rows.Close()
return readMessage(rows)
}
func (c *messageCache) MarkPublished(m *message) error { func (c *messageCache) MarkPublished(m *message) error {
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID) _, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
return err return err
@@ -563,8 +585,8 @@ func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error)
return c.readAttachmentBytesUsed(rows) return c.readAttachmentBytesUsed(rows)
} }
func (c *messageCache) AttachmentBytesUsedByUser(user string) (int64, error) { func (c *messageCache) AttachmentBytesUsedByUser(userID string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeByUserQuery, user, time.Now().Unix()) rows, err := c.db.Query(selectAttachmentsSizeByUserIDQuery, userID, time.Now().Unix())
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -591,7 +613,7 @@ func (c *messageCache) processMessageBatches() {
} }
for messages := range c.queue.Dequeue() { for messages := range c.queue.Dequeue() {
if err := c.addMessages(messages); err != nil { if err := c.addMessages(messages); err != nil {
log.Error("Message Cache: %s", err.Error()) log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
} }
} }
} }
@@ -600,75 +622,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
defer rows.Close() defer rows.Close()
messages := make([]*message, 0) messages := make([]*message, 0)
for rows.Next() { for rows.Next() {
var timestamp, expires, attachmentSize, attachmentExpires int64 m, err := readMessage(rows)
var priority int
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, encoding string
err := rows.Scan(
&id,
&timestamp,
&expires,
&topic,
&msg,
&title,
&priority,
&tagsStr,
&click,
&icon,
&actionsStr,
&attachmentName,
&attachmentType,
&attachmentSize,
&attachmentExpires,
&attachmentURL,
&sender,
&user,
&encoding,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var tags []string messages = append(messages, m)
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
var actions []*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 *attachment
if attachmentName != "" && attachmentURL != "" {
att = &attachment{
Name: attachmentName,
Type: attachmentType,
Size: attachmentSize,
Expires: attachmentExpires,
URL: attachmentURL,
}
}
messages = append(messages, &message{
ID: id,
Time: timestamp,
Expires: expires,
Event: messageEvent,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
Click: click,
Icon: icon,
Actions: actions,
Attachment: att,
Sender: senderIP, // Must parse assuming database must be correct
User: user,
Encoding: encoding,
})
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
@@ -676,6 +634,82 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
return messages, nil return messages, nil
} }
func readMessage(rows *sql.Rows) (*message, error) {
var timestamp, expires, attachmentSize, attachmentExpires int64
var priority int
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, encoding string
err := rows.Scan(
&id,
&timestamp,
&expires,
&topic,
&msg,
&title,
&priority,
&tagsStr,
&click,
&icon,
&actionsStr,
&attachmentName,
&attachmentType,
&attachmentSize,
&attachmentExpires,
&attachmentURL,
&sender,
&user,
&encoding,
)
if err != nil {
return nil, err
}
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
var actions []*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 *attachment
if attachmentName != "" && attachmentURL != "" {
att = &attachment{
Name: attachmentName,
Type: attachmentType,
Size: attachmentSize,
Expires: attachmentExpires,
URL: attachmentURL,
}
}
return &message{
ID: id,
Time: timestamp,
Expires: expires,
Event: messageEvent,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
Click: click,
Icon: icon,
Actions: actions,
Attachment: att,
Sender: senderIP, // Must parse assuming database must be correct
User: user,
Encoding: encoding,
}, nil
}
func (c *messageCache) Close() error {
return c.db.Close()
}
func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error { func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries // Run startup queries
if startupQueries != "" { if startupQueries != "" {
@@ -736,7 +770,7 @@ func setupNewCacheDB(db *sql.DB) error {
} }
func migrateFrom0(db *sql.DB, _ time.Duration) error { func migrateFrom0(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 0 to 1") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -750,7 +784,7 @@ func migrateFrom0(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom1(db *sql.DB, _ time.Duration) error { func migrateFrom1(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 1 to 2") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -761,7 +795,7 @@ func migrateFrom1(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom2(db *sql.DB, _ time.Duration) error { func migrateFrom2(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 2 to 3") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -772,7 +806,7 @@ func migrateFrom2(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom3(db *sql.DB, _ time.Duration) error { func migrateFrom3(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 3 to 4") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -783,7 +817,7 @@ func migrateFrom3(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom4(db *sql.DB, _ time.Duration) error { func migrateFrom4(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 4 to 5") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -794,7 +828,7 @@ func migrateFrom4(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom5(db *sql.DB, _ time.Duration) error { func migrateFrom5(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 5 to 6") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -805,7 +839,7 @@ func migrateFrom5(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom6(db *sql.DB, _ time.Duration) error { func migrateFrom6(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 6 to 7") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -816,7 +850,7 @@ func migrateFrom6(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom7(db *sql.DB, _ time.Duration) error { func migrateFrom7(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 7 to 8") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -827,7 +861,7 @@ func migrateFrom7(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom8(db *sql.DB, _ time.Duration) error { func migrateFrom8(db *sql.DB, _ time.Duration) error {
log.Info("Migrating cache database schema: from 8 to 9") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil { if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
return err return err
} }
@@ -838,7 +872,7 @@ func migrateFrom8(db *sql.DB, _ time.Duration) error {
} }
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error { func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
log.Info("Migrating cache database schema: from 9 to 10") log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return err return err
@@ -853,8 +887,5 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil { if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
return err return err
} }
if err := tx.Commit(); err != nil { return tx.Commit()
return err
}
return nil // Update this when a new version is added
} }

View File

@@ -12,10 +12,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var (
exampleIP1234 = netip.MustParseAddr("1.2.3.4")
)
func TestSqliteCache_Messages(t *testing.T) { func TestSqliteCache_Messages(t *testing.T) {
testCacheMessages(t, newSqliteTestCache(t)) testCacheMessages(t, newSqliteTestCache(t))
} }
@@ -294,10 +290,10 @@ func TestMemCache_Attachments(t *testing.T) {
} }
func testCacheAttachments(t *testing.T, c *messageCache) { func testCacheAttachments(t *testing.T, c *messageCache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
m := newDefaultMessage("mytopic", "flower for you") m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1" m.ID = "m1"
m.Sender = exampleIP1234 m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "flower.jpg", Name: "flower.jpg",
Type: "image/jpeg", Type: "image/jpeg",
@@ -310,7 +306,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = newDefaultMessage("mytopic", "sending you a car") m = newDefaultMessage("mytopic", "sending you a car")
m.ID = "m2" m.ID = "m2"
m.Sender = exampleIP1234 m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "car.jpg", Name: "car.jpg",
Type: "image/jpeg", Type: "image/jpeg",
@@ -323,7 +319,8 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = newDefaultMessage("another-topic", "sending you another car") m = newDefaultMessage("another-topic", "sending you another car")
m.ID = "m3" m.ID = "m3"
m.Sender = exampleIP1234 m.User = "u_BAsbaAa"
m.Sender = netip.MustParseAddr("5.6.7.8")
m.Attachment = &attachment{ m.Attachment = &attachment{
Name: "another-car.jpg", Name: "another-car.jpg",
Type: "image/jpeg", Type: "image/jpeg",
@@ -355,11 +352,15 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
size, err := c.AttachmentBytesUsedBySender("1.2.3.4") size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(30000), size) require.Equal(t, int64(10000), size)
size, err = c.AttachmentBytesUsedBySender("5.6.7.8") size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), size) 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) { func TestSqliteCache_Attachments_Expired(t *testing.T) {

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,8 @@
# - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist # - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist
# - auth-default-access defines the default/fallback access if no access control entry is found; it can be # - auth-default-access defines the default/fallback access if no access control entry is found; it can be
# set to "read-write" (default), "read-only", "write-only" or "deny-all". # set to "read-write" (default), "read-only", "write-only" or "deny-all".
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
# WAL mode. This is similar to cache-startup-queries. See above for details.
# #
# Debian/RPM package users: # Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package # Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
@@ -91,6 +93,7 @@
# #
# auth-file: <filename> # auth-file: <filename>
# auth-default-access: "read-write" # auth-default-access: "read-write"
# auth-startup-queries:
# If set, the X-Forwarded-For header is used to determine the visitor IP address # If set, the X-Forwarded-For header is used to determine the visitor IP address
# instead of the remote address of the connection. # instead of the remote address of the connection.
@@ -152,6 +155,17 @@
# #
# manager-interval: "1m" # manager-interval: "1m"
# Defines topic names that are not allowed, because they are otherwise used. There are a few default topics
# that cannot be used (e.g. app, account, settings, ...). To extend the default list, define them here.
#
# Example:
# disallowed-topics:
# - about
# - pricing
# - contact
#
# disallowed-topics:
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the # Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
# web app. If you self-host, you don't want to change this. # web app. If you self-host, you don't want to change this.
# Can be "app" (default), "home" or "disable" to disable the web app entirely. # Can be "app" (default), "home" or "disable" to disable the web app entirely.
@@ -200,6 +214,12 @@
# visitor-request-limit-replenish: "5s" # visitor-request-limit-replenish: "5s"
# visitor-request-limit-exempt-hosts: "" # visitor-request-limit-exempt-hosts: ""
# Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset
# every day at midnight UTC. If the limit is not set (or set to zero), the request
# limit (see above) governs the upper limit.
#
# visitor-message-daily-limit: 0
# Rate limiting: Allowed emails per visitor: # Rate limiting: Allowed emails per visitor:
# - visitor-email-limit-burst is the initial bucket of emails each visitor has # - visitor-email-limit-burst is the initial bucket of emails each visitor has
# - visitor-email-limit-replenish is the rate at which the bucket is refilled # - visitor-email-limit-replenish is the rate at which the bucket is refilled
@@ -224,10 +244,36 @@
# stripe-secret-key: # stripe-secret-key:
# stripe-webhook-key: # stripe-webhook-key:
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR # Logging options
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
# #
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for # By default, ntfy logs to the console (stderr), with an "info" log level, and in a human-readable text format.
# debugging purposes, or your disk will fill up quickly. # ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
# log level overrides for easier debugging. Some options (log-level and log-level-overrides) can be hot reloaded
# by calling "kill -HUP $pid" or "systemctl reload ntfy".
# #
# log-level: INFO # - log-format defines the output format, can be "text" (default) or "json"
# - log-file is a filename to write logs to. If this is not set, ntfy logs to stderr.
# - log-level defines the default log level, can be one of "trace", "debug", "info" (default), "warn" or "error".
# Be aware that "debug" (and particularly "trace") can be VERY CHATTY. Only turn them on briefly for debugging purposes.
# - log-level-overrides lets you override the log level if certain fields match. This is incredibly powerful
# for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
# This is an array of strings in the format:
# - "field=value -> level" to match a value exactly, e.g. "tag=manager -> trace"
# - "field -> level" to match any value, e.g. "time_taken_ms -> debug"
# Warning: Using log-level-overrides has a performance penalty. Only use it for temporary debugging.
#
# Example (good for production):
# log-level: info
# log-format: json
# log-file: /var/log/ntfy.log
#
# Example level overrides (for debugging, only use temporarily):
# log-level-overrides:
# - "tag=manager -> trace"
# - "visitor_ip=1.2.3.4 -> debug"
# - "time_taken_ms -> debug"
#
# log-level: info
# log-level-overrides:
# log-format: text
# log-file:

View File

@@ -2,60 +2,65 @@ package server
import ( import (
"encoding/json" "encoding/json"
"errors"
"heckel.io/ntfy/log" "heckel.io/ntfy/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"net/http" "net/http"
"net/netip"
"strings"
"time"
) )
const ( const (
subscriptionIDLength = 16
createdByAPI = "api"
syncTopicAccountSyncEvent = "sync" syncTopicAccountSyncEvent = "sync"
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
) )
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
admin := v.user != nil && v.user.Role == user.RoleAdmin u := v.User()
if !admin { if !u.IsAdmin() { // u may be nil, but that's fine
if !s.config.EnableSignup { if !s.config.EnableSignup {
return errHTTPBadRequestSignupNotEnabled return errHTTPBadRequestSignupNotEnabled
} else if v.user != nil { } else if u != nil {
return errHTTPUnauthorized // Cannot create account from user context return errHTTPUnauthorized // Cannot create account from user context
} }
if !v.AccountCreationAllowed() {
return errHTTPTooManyRequestsLimitAccountCreation
}
} }
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit) newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
return err return err
} }
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil { if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
return errHTTPConflictUserExists return errHTTPConflictUserExists
} }
if v.accountLimiter != nil && !v.accountLimiter.Allow() { logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username)
return errHTTPTooManyRequestsLimitAccountCreation if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil {
}
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, createdByAPI); err != nil { // TODO this should return a User
return err return err
} }
v.AccountCreated()
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error { func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
info, err := v.Info() info, err := v.Info()
if err != nil { if err != nil {
return err return err
} }
logvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug("Retrieving account stats")
limits, stats := info.Limits, info.Stats limits, stats := info.Limits, info.Stats
response := &apiAccountResponse{ response := &apiAccountResponse{
Limits: &apiAccountLimits{ Limits: &apiAccountLimits{
Basis: string(limits.Basis), Basis: string(limits.Basis),
Messages: limits.MessagesLimit, Messages: limits.MessageLimit,
MessagesExpiryDuration: int64(limits.MessagesExpiryDuration.Seconds()), MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
Emails: limits.EmailsLimit, Emails: limits.EmailLimit,
Reservations: limits.ReservationsLimit, Reservations: limits.ReservationsLimit,
AttachmentTotalSize: limits.AttachmentTotalSizeLimit, AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
AttachmentFileSize: limits.AttachmentFileSizeLimit, AttachmentFileSize: limits.AttachmentFileSizeLimit,
AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()), AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),
AttachmentBandwidth: limits.AttachmentBandwidthLimit,
}, },
Stats: &apiAccountStats{ Stats: &apiAccountStats{
Messages: stats.Messages, Messages: stats.Messages,
@@ -68,37 +73,38 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining, AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
}, },
} }
if v.user != nil { u := v.User()
response.Username = v.user.Name if u != nil {
response.Role = string(v.user.Role) response.Username = u.Name
response.SyncTopic = v.user.SyncTopic response.Role = string(u.Role)
if v.user.Prefs != nil { response.SyncTopic = u.SyncTopic
if v.user.Prefs.Language != "" { if u.Prefs != nil {
response.Language = v.user.Prefs.Language if u.Prefs.Language != nil {
response.Language = *u.Prefs.Language
} }
if v.user.Prefs.Notification != nil { if u.Prefs.Notification != nil {
response.Notification = v.user.Prefs.Notification response.Notification = u.Prefs.Notification
} }
if v.user.Prefs.Subscriptions != nil { if u.Prefs.Subscriptions != nil {
response.Subscriptions = v.user.Prefs.Subscriptions response.Subscriptions = u.Prefs.Subscriptions
} }
} }
if v.user.Tier != nil { if u.Tier != nil {
response.Tier = &apiAccountTier{ response.Tier = &apiAccountTier{
Code: v.user.Tier.Code, Code: u.Tier.Code,
Name: v.user.Tier.Name, Name: u.Tier.Name,
} }
} }
if v.user.Billing.StripeCustomerID != "" { if u.Billing.StripeCustomerID != "" {
response.Billing = &apiAccountBilling{ response.Billing = &apiAccountBilling{
Customer: true, Customer: true,
Subscription: v.user.Billing.StripeSubscriptionID != "", Subscription: u.Billing.StripeSubscriptionID != "",
Status: string(v.user.Billing.StripeSubscriptionStatus), Status: string(u.Billing.StripeSubscriptionStatus),
PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(), PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(), CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
} }
} }
reservations, err := s.userManager.Reservations(v.user.Name) reservations, err := s.userManager.Reservations(u.Name)
if err != nil { if err != nil {
return err return err
} }
@@ -111,6 +117,26 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
}) })
} }
} }
tokens, err := s.userManager.Tokens(u.ID)
if err != nil {
return err
}
if len(tokens) > 0 {
response.Tokens = make([]*apiAccountTokenResponse, 0)
for _, t := range tokens {
var lastOrigin string
if t.LastOrigin != netip.IPv4Unspecified() {
lastOrigin = t.LastOrigin.String()
}
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
Token: t.Value,
Label: t.Label,
LastAccess: t.LastAccess.Unix(),
LastOrigin: lastOrigin,
Expires: t.Expires.Unix(),
})
}
}
} else { } else {
response.Username = user.Everyone response.Username = user.Everyone
response.Role = string(user.RoleAnonymous) response.Role = string(user.RoleAnonymous)
@@ -118,149 +144,213 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
return s.writeJSON(w, response) return s.writeJSON(w, response)
} }
func (s *Server) handleAccountDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error { func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user.Billing.StripeSubscriptionID != "" { req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false)
log.Info("Deleting user %s (billing customer: %s, billing subscription: %s)", v.user.Name, v.user.Billing.StripeCustomerID, v.user.Billing.StripeSubscriptionID) if err != nil {
if v.user.Billing.StripeSubscriptionID != "" { return err
if _, err := s.stripe.CancelSubscription(v.user.Billing.StripeSubscriptionID); err != nil { } else if req.Password == "" {
return err return errHTTPBadRequest
}
}
} else {
log.Info("Deleting user %s", v.user.Name)
} }
if err := s.userManager.RemoveUser(v.user.Name); err != nil { u := v.User()
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
return errHTTPBadRequestIncorrectPasswordConfirmation
}
if u.Billing.StripeSubscriptionID != "" {
logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {
return err
}
}
if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, 0); err != nil {
return err
}
logvr(v, r).Tag(tagAccount).Info("Marking user %s as deleted", u.Name)
if err := s.userManager.MarkUserRemoved(u); err != nil {
return err return err
} }
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
newPassword, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit) req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
return err return err
} else if req.Password == "" || req.NewPassword == "" {
return errHTTPBadRequest
} }
if err := s.userManager.ChangePassword(v.user.Name, newPassword.Password); err != nil { u := v.User()
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
return errHTTPBadRequestIncorrectPasswordConfirmation
}
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
if err := s.userManager.ChangePassword(u.Name, req.NewPassword); err != nil {
return err return err
} }
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error { func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
token, err := s.userManager.CreateToken(v.user) if err != nil {
return err
}
var label string
if req.Label != nil {
label = *req.Label
}
expires := time.Now().Add(tokenExpiryDuration)
if req.Expires != nil {
expires = time.Unix(*req.Expires, 0)
}
u := v.User()
logvr(v, r).
Tag(tagAccount).
Fields(log.Context{
"token_label": label,
"token_expires": expires,
}).
Debug("Creating token for user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
if err != nil { if err != nil {
return err return err
} }
response := &apiAccountTokenResponse{ response := &apiAccountTokenResponse{
Token: token.Value, Token: token.Value,
Expires: token.Expires.Unix(), Label: token.Label,
LastAccess: token.LastAccess.Unix(),
LastOrigin: token.LastOrigin.String(),
Expires: token.Expires.Unix(),
} }
return s.writeJSON(w, response) return s.writeJSON(w, response)
} }
func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error { func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit u := v.User()
if v.user == nil { req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
return errHTTPUnauthorized if err != nil {
} else if v.user.Token == "" { return err
return errHTTPBadRequestNoTokenProvided } else if req.Token == "" {
req.Token = u.Token
if req.Token == "" {
return errHTTPBadRequestNoTokenProvided
}
} }
token, err := s.userManager.ExtendToken(v.user) var expires *time.Time
if req.Expires != nil {
expires = util.Time(time.Unix(*req.Expires, 0))
} else if req.Label == nil {
expires = util.Time(time.Now().Add(tokenExpiryDuration)) // If label/expires not set, extend token by 72 hours
}
logvr(v, r).
Tag(tagAccount).
Fields(log.Context{
"token_label": req.Label,
"token_expires": expires,
}).
Debug("Updating token for user %s as deleted", u.Name)
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
if err != nil { if err != nil {
return err return err
} }
response := &apiAccountTokenResponse{ response := &apiAccountTokenResponse{
Token: token.Value, Token: token.Value,
Expires: token.Expires.Unix(), Label: token.Label,
LastAccess: token.LastAccess.Unix(),
LastOrigin: token.LastOrigin.String(),
Expires: token.Expires.Unix(),
} }
return s.writeJSON(w, response) return s.writeJSON(w, response)
} }
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error { func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit u := v.User()
if v.user.Token == "" { token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path
return errHTTPBadRequestNoTokenProvided if token == "" {
token = u.Token
if token == "" {
return errHTTPBadRequestNoTokenProvided
}
} }
if err := s.userManager.RemoveToken(v.user); err != nil { if err := s.userManager.RemoveToken(u.ID, token); err != nil {
return err return err
} }
logvr(v, r).
Tag(tagAccount).
Field("token", token).
Debug("Deleted token for user %s", u.Name)
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit) newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
return err return err
} }
if v.user.Prefs == nil { u := v.User()
v.user.Prefs = &user.Prefs{} if u.Prefs == nil {
u.Prefs = &user.Prefs{}
} }
prefs := v.user.Prefs prefs := u.Prefs
if newPrefs.Language != "" { if newPrefs.Language != nil {
prefs.Language = newPrefs.Language prefs.Language = newPrefs.Language
} }
if newPrefs.Notification != nil { if newPrefs.Notification != nil {
if prefs.Notification == nil { if prefs.Notification == nil {
prefs.Notification = &user.NotificationPrefs{} prefs.Notification = &user.NotificationPrefs{}
} }
if newPrefs.Notification.DeleteAfter > 0 { if newPrefs.Notification.DeleteAfter != nil {
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
} }
if newPrefs.Notification.Sound != "" { if newPrefs.Notification.Sound != nil {
prefs.Notification.Sound = newPrefs.Notification.Sound prefs.Notification.Sound = newPrefs.Notification.Sound
} }
if newPrefs.Notification.MinPriority > 0 { if newPrefs.Notification.MinPriority != nil {
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
} }
} }
if err := s.userManager.ChangeSettings(v.user); err != nil { logvr(v, r).Tag(tagAccount).Debug("Changing account settings for user %s", u.Name)
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
return err return err
} }
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit) newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
return err return err
} }
if v.user.Prefs == nil { u := v.User()
v.user.Prefs = &user.Prefs{} prefs := u.Prefs
if prefs == nil {
prefs = &user.Prefs{}
} }
newSubscription.ID = "" // Client cannot set ID for _, subscription := range prefs.Subscriptions {
for _, subscription := range v.user.Prefs.Subscriptions {
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic { if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
newSubscription = subscription return errHTTPConflictSubscriptionExists
break
} }
} }
if newSubscription.ID == "" { prefs.Subscriptions = append(prefs.Subscriptions, newSubscription)
newSubscription.ID = util.RandomString(subscriptionIDLength) logvr(v, r).Tag(tagAccount).With(newSubscription).Debug("Adding subscription for user %s", u.Name)
v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, newSubscription) if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
if err := s.userManager.ChangeSettings(v.user); err != nil { return err
return err
}
} }
return s.writeJSON(w, newSubscription) return s.writeJSON(w, newSubscription)
} }
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path) updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
if len(matches) != 2 {
return errHTTPInternalErrorInvalidPath
}
subscriptionID := matches[1]
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
if err != nil { if err != nil {
return err return err
} }
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil { u := v.User()
prefs := u.Prefs
if prefs == nil || prefs.Subscriptions == nil {
return errHTTPNotFound return errHTTPNotFound
} }
var subscription *user.Subscription var subscription *user.Subscription
for _, sub := range v.user.Prefs.Subscriptions { for _, sub := range prefs.Subscriptions {
if sub.ID == subscriptionID { if sub.BaseURL == updatedSubscription.BaseURL && sub.Topic == updatedSubscription.Topic {
sub.DisplayName = updatedSubscription.DisplayName sub.DisplayName = updatedSubscription.DisplayName
subscription = sub subscription = sub
break break
@@ -269,41 +359,45 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
if subscription == nil { if subscription == nil {
return errHTTPNotFound return errHTTPNotFound
} }
if err := s.userManager.ChangeSettings(v.user); err != nil { logvr(v, r).Tag(tagAccount).With(subscription).Debug("Changing subscription for user %s", u.Name)
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
return err return err
} }
return s.writeJSON(w, subscription) return s.writeJSON(w, subscription)
} }
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path) // DELETEs cannot have a body, and we don't want it in the path
if len(matches) != 2 { deleteBaseURL := readParam(r, "X-BaseURL", "BaseURL")
return errHTTPInternalErrorInvalidPath deleteTopic := readParam(r, "X-Topic", "Topic")
} u := v.User()
subscriptionID := matches[1] prefs := u.Prefs
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil { if prefs == nil || prefs.Subscriptions == nil {
return nil return nil
} }
newSubscriptions := make([]*user.Subscription, 0) newSubscriptions := make([]*user.Subscription, 0)
for _, subscription := range v.user.Prefs.Subscriptions { for _, sub := range u.Prefs.Subscriptions {
if subscription.ID != subscriptionID { if sub.BaseURL == deleteBaseURL && sub.Topic == deleteTopic {
newSubscriptions = append(newSubscriptions, subscription) logvr(v, r).Tag(tagAccount).With(sub).Debug("Removing subscription for user %s", u.Name)
} else {
newSubscriptions = append(newSubscriptions, sub)
} }
} }
if len(newSubscriptions) < len(v.user.Prefs.Subscriptions) { if len(newSubscriptions) < len(prefs.Subscriptions) {
v.user.Prefs.Subscriptions = newSubscriptions prefs.Subscriptions = newSubscriptions
if err := s.userManager.ChangeSettings(v.user); err != nil { if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
return err return err
} }
} }
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
// handleAccountReservationAdd adds a topic reservation for the logged-in user, but only if the user has a tier
// with enough remaining reservations left, or if the user is an admin. Admins can always reserve a topic, unless
// it is already reserved by someone else.
func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user != nil && v.user.Role == user.RoleAdmin { u := v.User()
return errHTTPBadRequestMakesNoSenseForAdmin req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false)
}
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit)
if err != nil { if err != nil {
return err return err
} }
@@ -314,30 +408,46 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
if err != nil { if err != nil {
return errHTTPBadRequestPermissionInvalid return errHTTPBadRequestPermissionInvalid
} }
if v.user.Tier == nil { // Check if we are allowed to reserve this topic
if u.IsUser() && u.Tier == nil {
return errHTTPUnauthorized return errHTTPUnauthorized
} } else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
return errHTTPConflictTopicReserved return errHTTPConflictTopicReserved
} else if u.IsUser() {
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
if err != nil {
return err
}
if !hasReservation {
reservations, err := s.userManager.ReservationsCount(u.Name)
if err != nil {
return err
} else if reservations >= u.Tier.ReservationLimit {
return errHTTPTooManyRequestsLimitReservations
}
}
} }
hasReservation, err := s.userManager.HasReservation(v.user.Name, req.Topic) // Actually add the reservation
logvr(v, r).
Tag(tagAccount).
Fields(log.Context{
"topic": req.Topic,
"everyone": everyone.String(),
}).
Debug("Adding topic reservation")
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil {
return err
}
// Kill existing subscribers
t, err := s.topicFromID(req.Topic)
if err != nil { if err != nil {
return err return err
} }
if !hasReservation { t.CancelSubscribers(u.ID)
reservations, err := s.userManager.ReservationsCount(v.user.Name)
if err != nil {
return err
} else if reservations >= v.user.Tier.ReservationsLimit {
return errHTTPTooManyRequestsLimitReservations
}
}
if err := s.userManager.ReserveAccess(v.user.Name, req.Topic, everyone); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
// handleAccountReservationDelete deletes a topic reservation if it is owned by the current user
func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path) matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path)
if len(matches) != 2 { if len(matches) != 2 {
@@ -347,30 +457,78 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
if !topicRegex.MatchString(topic) { if !topicRegex.MatchString(topic) {
return errHTTPBadRequestTopicInvalid return errHTTPBadRequestTopicInvalid
} }
authorized, err := s.userManager.HasReservation(v.user.Name, topic) u := v.User()
authorized, err := s.userManager.HasReservation(u.Name, topic)
if err != nil { if err != nil {
return err return err
} else if !authorized { } else if !authorized {
return errHTTPUnauthorized return errHTTPUnauthorized
} }
if err := s.userManager.RemoveReservations(v.user.Name, topic); err != nil { deleteMessages := readBoolParam(r, false, "X-Delete-Messages", "Delete-Messages")
logvr(v, r).
Tag(tagAccount).
Fields(log.Context{
"topic": topic,
"delete_messages": deleteMessages,
}).
Debug("Removing topic reservation")
if err := s.userManager.RemoveReservations(u.Name, topic); err != nil {
return err return err
} }
if deleteMessages {
if err := s.messageCache.ExpireMessages(topic); err != nil {
return err
}
s.pruneMessages()
}
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
func (s *Server) publishSyncEvent(v *visitor) error { // maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier),
if v.user == nil || v.user.SyncTopic == "" { // and marks associated messages for the topics as deleted. This also eventually deletes attachments.
return nil // The process relies on the manager to perform the actual deletions (see runManager).
} func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {
log.Trace("Publishing sync event to user %s's sync topic %s", v.user.Name, v.user.SyncTopic) reservations, err := s.userManager.Reservations(u.Name)
topics, err := s.topicsFromIDs(v.user.SyncTopic) if err != nil {
return err
} else if int64(len(reservations)) <= reservationsLimit {
logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove")
return nil
}
topics := make([]string, 0)
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
topics = append(topics, reservations[i].Topic)
}
logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", "))
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
return err
}
if err := s.messageCache.ExpireMessages(topics...); err != nil {
return err
}
return nil
}
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) {
go func() {
if err := s.publishSyncEvent(v); err != nil {
logv(v).Err(err).Trace("Error publishing to user's sync topic")
}
}()
}
// publishSyncEvent publishes a sync message to the user's sync topic
func (s *Server) publishSyncEvent(v *visitor) error {
u := v.User()
if u == nil || u.SyncTopic == "" {
return nil
}
logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic")
syncTopic, err := s.topicFromID(u.SyncTopic)
if err != nil { if err != nil {
return err return err
} else if len(topics) == 0 {
return errors.New("cannot retrieve sync topic")
} }
syncTopic := topics[0]
messageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent}) messageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent})
if err != nil { if err != nil {
return err return err
@@ -381,14 +539,3 @@ func (s *Server) publishSyncEvent(v *visitor) error {
} }
return nil return nil
} }
func (s *Server) publishSyncEventAsync(v *visitor) {
go func() {
if v.user == nil || v.user.SyncTopic == "" {
return
}
if err := s.publishSyncEvent(v); err != nil {
log.Trace("Error publishing to user %s's sync topic %s: %s", v.user.Name, v.user.SyncTopic, err.Error())
}
}()
}

View File

@@ -3,9 +3,13 @@ package server
import ( import (
"fmt" "fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"io" "io"
"net/netip"
"path/filepath"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -14,6 +18,7 @@ func TestAccount_Signup_Success(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) s := newTestServer(t, conf)
defer s.closeDatabases()
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@@ -25,6 +30,10 @@ func TestAccount_Signup_Success(t *testing.T) {
token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
require.NotEmpty(t, token.Token) require.NotEmpty(t, token.Token)
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires) require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)
require.True(t, strings.HasPrefix(token.Token, "tk_"))
require.Equal(t, "9.9.9.9", token.LastOrigin)
require.True(t, token.LastAccess > time.Now().Unix()-2)
require.True(t, token.LastAccess < time.Now().Unix()+2)
rr = request(t, s, "GET", "/v1/account", "", map[string]string{ rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BearerAuth(token.Token), "Authorization": util.BearerAuth(token.Token),
@@ -33,12 +42,20 @@ func TestAccount_Signup_Success(t *testing.T) {
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, "phil", account.Username) require.Equal(t, "phil", account.Username)
require.Equal(t, "user", account.Role) require.Equal(t, "user", account.Role)
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("", token.Token), // We allow a fake basic auth to make curl-ing easier (curl -u :<token>)
})
require.Equal(t, 200, rr.Code)
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, "phil", account.Username)
} }
func TestAccount_Signup_UserExists(t *testing.T) { func TestAccount_Signup_UserExists(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) s := newTestServer(t, conf)
defer s.closeDatabases()
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@@ -52,6 +69,7 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) s := newTestServer(t, conf)
defer s.closeDatabases()
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
@@ -66,15 +84,18 @@ func TestAccount_Signup_AsUser(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test")) log.Info("1")
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
log.Info("2")
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
log.Info("3")
rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{ rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
log.Info("4")
rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{ rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"), "Authorization": util.BasicAuth("ben", "ben"),
}) })
@@ -85,12 +106,27 @@ func TestAccount_Signup_Disabled(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = false conf.EnableSignup = false
s := newTestServer(t, conf) s := newTestServer(t, conf)
defer s.closeDatabases()
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 400, rr.Code) require.Equal(t, 400, rr.Code)
require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code) require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code)
} }
func TestAccount_Signup_Rate_Limit(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true
s := newTestServer(t, conf)
for i := 0; i < 3; i++ {
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
}
rr := request(t, s, "POST", "/v1/account", `{"username":"notallowed", "password":"mypass"}`, nil)
require.Equal(t, 429, rr.Code)
require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code)
}
func TestAccount_Get_Anonymous(t *testing.T) { func TestAccount_Get_Anonymous(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.VisitorRequestLimitReplenish = 86 * time.Second conf.VisitorRequestLimitReplenish = 86 * time.Second
@@ -99,6 +135,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
conf.AttachmentFileSizeLimit = 512 conf.AttachmentFileSizeLimit = 512
s := newTestServer(t, conf) s := newTestServer(t, conf)
s.smtpSender = &testMailer{} s.smtpSender = &testMailer{}
defer s.closeDatabases()
rr := request(t, s, "GET", "/v1/account", "", nil) rr := request(t, s, "GET", "/v1/account", "", nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@@ -133,9 +170,11 @@ func TestAccount_Get_Anonymous(t *testing.T) {
func TestAccount_ChangeSettings(t *testing.T) { func TestAccount_ChangeSettings(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) defer s.closeDatabases()
user, _ := s.userManager.User("phil")
token, _ := s.userManager.CreateToken(user) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
u, _ := s.userManager.User("phil")
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{ rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
@@ -153,14 +192,16 @@ func TestAccount_ChangeSettings(t *testing.T) {
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, "de", account.Language) require.Equal(t, "de", account.Language)
require.Equal(t, 86400, account.Notification.DeleteAfter) require.Equal(t, util.Int(86400), account.Notification.DeleteAfter)
require.Equal(t, "juntos", account.Notification.Sound) require.Equal(t, util.String("juntos"), account.Notification.Sound)
require.Equal(t, 0, account.Notification.MinPriority) // Not set require.Nil(t, account.Notification.MinPriority) // Not set
} }
func TestAccount_Subscription_AddUpdateDelete(t *testing.T) { func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{ rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
@@ -173,13 +214,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, 1, len(account.Subscriptions)) require.Equal(t, 1, len(account.Subscriptions))
require.NotEmpty(t, account.Subscriptions[0].ID)
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
require.Equal(t, "def", account.Subscriptions[0].Topic) require.Equal(t, "def", account.Subscriptions[0].Topic)
require.Equal(t, "", account.Subscriptions[0].DisplayName) require.Nil(t, account.Subscriptions[0].DisplayName)
subscriptionID := account.Subscriptions[0].ID rr = request(t, s, "PATCH", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def", "display_name": "ding dong"}`, map[string]string{
rr = request(t, s, "PATCH", "/v1/account/subscription/"+subscriptionID, `{"display_name": "ding dong"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@@ -190,13 +229,14 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, 1, len(account.Subscriptions)) require.Equal(t, 1, len(account.Subscriptions))
require.Equal(t, subscriptionID, account.Subscriptions[0].ID)
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL) require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
require.Equal(t, "def", account.Subscriptions[0].Topic) require.Equal(t, "def", account.Subscriptions[0].Topic)
require.Equal(t, "ding dong", account.Subscriptions[0].DisplayName) require.Equal(t, util.String("ding dong"), account.Subscriptions[0].DisplayName)
rr = request(t, s, "DELETE", "/v1/account/subscription/"+subscriptionID, "", map[string]string{ rr = request(t, s, "DELETE", "/v1/account/subscription", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
"X-BaseURL": "http://abc.com",
"X-Topic": "def",
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@@ -210,9 +250,22 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
func TestAccount_ChangePassword(t *testing.T) { func TestAccount_ChangePassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) defer s.closeDatabases()
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, map[string]string{ require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
rr = request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": "new password"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
rr = request(t, s, "POST", "/v1/account/password", `{"password": "phil", "new_password": "new password"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@@ -230,6 +283,7 @@ func TestAccount_ChangePassword(t *testing.T) {
func TestAccount_ChangePassword_NoAccount(t *testing.T) { func TestAccount_ChangePassword_NoAccount(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil) rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil)
require.Equal(t, 401, rr.Code) require.Equal(t, 401, rr.Code)
@@ -237,7 +291,9 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) {
func TestAccount_ExtendToken(t *testing.T) { func TestAccount_ExtendToken(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
@@ -256,11 +312,24 @@ func TestAccount_ExtendToken(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, token.Token, extendedToken.Token) require.Equal(t, token.Token, extendedToken.Token)
require.True(t, token.Expires < extendedToken.Expires) require.True(t, token.Expires < extendedToken.Expires)
expires := time.Now().Add(999 * time.Hour)
body := fmt.Sprintf(`{"token":"%s", "label":"some label", "expires": %d}`, token.Token, expires.Unix())
rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{
"Authorization": util.BearerAuth(token.Token),
})
require.Equal(t, 200, rr.Code)
token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
require.Nil(t, err)
require.Equal(t, "some label", token.Label)
require.Equal(t, expires.Unix(), token.Expires)
} }
func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) { func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{ rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer! "Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
@@ -271,7 +340,9 @@ func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
func TestAccount_DeleteToken(t *testing.T) { func TestAccount_DeleteToken(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t)) s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{ rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
@@ -279,6 +350,7 @@ func TestAccount_DeleteToken(t *testing.T) {
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
require.Nil(t, err) require.Nil(t, err)
require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix())
// Delete token failure (using basic auth) // Delete token failure (using basic auth)
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{ rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
@@ -319,15 +391,20 @@ func TestAccount_Delete_Success(t *testing.T) {
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
rr = request(t, s, "DELETE", "/v1/account", "", map[string]string{ rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"), "Authorization": util.BasicAuth("phil", "mypass"),
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
// Account was marked deleted
rr = request(t, s, "GET", "/v1/account", "", map[string]string{ rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"), "Authorization": util.BasicAuth("phil", "mypass"),
}) })
require.Equal(t, 401, rr.Code) require.Equal(t, 401, rr.Code)
// Cannot re-create account, since still exists
rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 409, rr.Code)
} }
func TestAccount_Delete_Not_Allowed(t *testing.T) { func TestAccount_Delete_Not_Allowed(t *testing.T) {
@@ -340,6 +417,15 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
rr = request(t, s, "DELETE", "/v1/account", "", nil) rr = request(t, s, "DELETE", "/v1/account", "", nil)
require.Equal(t, 401, rr.Code) require.Equal(t, 401, rr.Code)
rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, nil)
require.Equal(t, 401, rr.Code)
rr = request(t, s, "DELETE", "/v1/account", `{"password":"INCORRECT"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 400, rr.Code)
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
} }
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) { func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
@@ -360,13 +446,52 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
conf := newTestConfigWithAuthFile(t) conf := newTestConfigWithAuthFile(t)
conf.EnableSignup = true conf.EnableSignup = true
s := newTestServer(t, conf) s := newTestServer(t, conf)
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin, "unit-test"))
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{ // A user, an admin, and a reservation walk into a bar
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
ReservationLimit: 2,
}))
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro"))
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro"))
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin))
// Admin can reserve topic
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "adminpass"), "Authorization": util.BasicAuth("phil", "adminpass"),
}) })
require.Equal(t, 400, rr.Code) require.Equal(t, 200, rr.Code)
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
// User cannot reserve already reserved topic
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{
"Authorization": util.BasicAuth("noadmin2", "pass"),
})
require.Equal(t, 409, rr.Code)
// Admin cannot reserve already reserved topic
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "adminpass"),
})
require.Equal(t, 409, rr.Code)
reservations, err := s.userManager.Reservations("phil")
require.Nil(t, err)
require.Equal(t, 1, len(reservations))
require.Equal(t, "sometopic", reservations[0].Topic)
reservations, err = s.userManager.Reservations("noadmin1")
require.Nil(t, err)
require.Equal(t, 1, len(reservations))
require.Equal(t, "mytopic", reservations[0].Topic)
reservations, err = s.userManager.Reservations("noadmin2")
require.Nil(t, err)
require.Equal(t, 0, len(reservations))
} }
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
@@ -379,16 +504,16 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
// Create a tier // Create a tier
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro", Code: "pro",
Paid: false, MessageLimit: 123,
MessagesLimit: 123, MessageExpiryDuration: 86400 * time.Second,
MessagesExpiryDuration: 86400 * time.Second, EmailLimit: 32,
EmailsLimit: 32, ReservationLimit: 2,
ReservationsLimit: 2,
AttachmentFileSizeLimit: 1231231, AttachmentFileSizeLimit: 1231231,
AttachmentTotalSizeLimit: 123123, AttachmentTotalSizeLimit: 123123,
AttachmentExpiryDuration: 10800 * time.Second, AttachmentExpiryDuration: 10800 * time.Second,
AttachmentBandwidthLimit: 21474836480,
})) }))
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
@@ -429,6 +554,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration) require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)
require.Equal(t, int64(21474836480), account.Limits.AttachmentBandwidth)
require.Equal(t, 2, len(account.Reservations)) require.Equal(t, 2, len(account.Reservations))
require.Equal(t, "another", account.Reservations[0].Topic) require.Equal(t, "another", account.Reservations[0].Topic)
require.Equal(t, "write-only", account.Reservations[0].Everyone) require.Equal(t, "write-only", account.Reservations[0].Everyone)
@@ -460,10 +586,10 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro", Code: "pro",
MessagesLimit: 20, MessageLimit: 20,
ReservationsLimit: 2, ReservationLimit: 2,
})) }))
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
@@ -483,3 +609,222 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
rr = request(t, s, "POST", "/mytopic", `Howdy`, nil) rr = request(t, s, "POST", "/mytopic", `Howdy`, nil)
require.Equal(t, 403, rr.Code) require.Equal(t, 403, rr.Code)
} }
func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
s := newTestServer(t, conf)
// Create user with tier
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 20,
MessageExpiryDuration: time.Hour,
ReservationLimit: 2,
AttachmentTotalSizeLimit: 10000,
AttachmentFileSizeLimit: 10000,
AttachmentExpiryDuration: time.Hour,
AttachmentBandwidthLimit: 10000,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Reserve two topics "mytopic1" and "mytopic2"
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic1", "everyone":"deny-all"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic2", "everyone":"deny-all"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
// Publish a message with attachment to each topic
rr = request(t, s, "POST", "/mytopic1?f=attach.txt", `Howdy`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
m1 := toMessage(t, rr.Body.String())
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
rr = request(t, s, "POST", "/mytopic2?f=attach.txt", `Howdy`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
m2 := toMessage(t, rr.Body.String())
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
// Delete reservation
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
"X-Delete-Messages": "true",
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic2", ``, map[string]string{
"X-Delete-Messages": "false",
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
// Verify that messages and attachments were deleted
// This does not explicitly call the manager!
time.Sleep(time.Second)
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 0, len(ms))
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(ms))
require.Equal(t, m2.ID, ms[0].ID)
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
}
func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
conf.EnableSignup = true
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create user with tier
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
require.Equal(t, 200, rr.Code)
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 20,
ReservationLimit: 2,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Subscribe anonymously
anonCh, userCh := make(chan bool), make(chan bool)
go func() {
rr := request(t, s, "GET", "/mytopic/json", ``, nil) // This blocks until it's killed!
require.Equal(t, 200, rr.Code)
messages := toMessages(t, rr.Body.String())
require.Equal(t, 2, len(messages)) // This is the meat. We should NOT receive the second message!
require.Equal(t, "open", messages[0].Event)
require.Equal(t, "message before reservation", messages[1].Message)
anonCh <- true
log.Info("Anonymous subscription ended")
}()
// Subscribe with user
go func() {
rr := request(t, s, "GET", "/mytopic/json", ``, map[string]string{ // Blocks!
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
messages := toMessages(t, rr.Body.String())
require.Equal(t, 3, len(messages))
require.Equal(t, "open", messages[0].Event)
require.Equal(t, "message before reservation", messages[1].Message)
require.Equal(t, "message after reservation", messages[2].Message)
userCh <- true
log.Info("User subscription ended")
}()
// Publish message (before reservation)
time.Sleep(2 * time.Second) // Wait for subscribers
rr = request(t, s, "POST", "/mytopic", "message before reservation", nil)
require.Equal(t, 200, rr.Code)
time.Sleep(2 * time.Second) // Wait for subscribers to receive message
// Reserve a topic
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
// Everyone but phil should be killed
select {
case <-anonCh:
case <-time.After(5 * time.Second):
t.Fatal("Waiting for anonymous subscription to be killed failed")
}
// Publish a message
rr = request(t, s, "POST", "/mytopic", "message after reservation", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
// Kill user Go routine
s.topics["mytopic"].CancelSubscribers("<invalid>")
select {
case <-userCh:
case <-time.After(5 * time.Second):
t.Fatal("Waiting for user subscription to be killed failed")
}
}
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create user with tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "starter",
MessageLimit: 10,
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 20,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "starter"))
// Publish a message
rr := request(t, s, "POST", "/mytopic", "hi", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Wait for stats queue writer
time.Sleep(300 * time.Millisecond)
// Verify that message stats were persisted
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, int64(1), u.Stats.Messages)
// Change tier, make a request (to reset limiters)
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(1), account.Stats.Messages) // Is not reset!
// Publish another message
rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Verify that message stats were persisted
time.Sleep(300 * time.Millisecond)
u, err = s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
// Stats keep counting
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(2), account.Stats.Messages) // Is not reset!
}

View File

@@ -8,7 +8,6 @@ import (
"firebase.google.com/go/v4/messaging" "firebase.google.com/go/v4/messaging"
"fmt" "fmt"
"google.golang.org/api/option" "google.golang.org/api/option"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"strings" "strings"
@@ -39,19 +38,23 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien
} }
func (c *firebaseClient) Send(v *visitor, m *message) error { func (c *firebaseClient) Send(v *visitor, m *message) error {
if err := v.FirebaseAllowed(); err != nil { if !v.FirebaseAllowed() {
return errFirebaseTemporarilyBanned return errFirebaseTemporarilyBanned
} }
fbm, err := toFirebaseMessage(m, c.auther) fbm, err := toFirebaseMessage(m, c.auther)
if err != nil { if err != nil {
return err return err
} }
if log.IsTrace() { ev := logvm(v, m).Tag(tagFirebase)
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm)) if ev.IsTrace() {
ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message")
} }
err = c.sender.Send(fbm) err = c.sender.Send(fbm)
if err == errFirebaseQuotaExceeded { if err == errFirebaseQuotaExceeded {
log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m)) logvm(v, m).
Tag(tagFirebase).
Err(err).
Warn("Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor")
v.FirebaseTemporarilyDeny() v.FirebaseTemporarilyDeny()
} }
return err return err

163
server/server_manager.go Normal file
View File

@@ -0,0 +1,163 @@
package server
import (
"heckel.io/ntfy/log"
"strings"
)
func (s *Server) execManager() {
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
// there is no mutex for the entire function.
// Prune all the things
s.pruneVisitors()
s.pruneTokens()
s.pruneAttachments()
s.pruneMessages()
// Message count per topic
var messagesCached int
messageCounts, err := s.messageCache.MessageCounts()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Cannot get message counts")
messageCounts = make(map[string]int) // Empty, so we can continue
}
for _, count := range messageCounts {
messagesCached += count
}
// Remove subscriptions without subscribers
var emptyTopics, subscribers int
log.
Tag(tagManager).
Timing(func() {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.topics {
subs := t.SubscribersCount()
log.Tag(tagManager).Trace("- topic %s: %d subscribers", t.ID, subs)
msgs, exists := messageCounts[t.ID]
if subs == 0 && (!exists || msgs == 0) {
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
emptyTopics++
delete(s.topics, t.ID)
continue
}
subscribers += subs
}
}).
Debug("Removed %d empty topic(s)", emptyTopics)
// Mail stats
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
if s.smtpServerBackend != nil {
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
}
var sentMailTotal, sentMailSuccess, sentMailFailure int64
if s.smtpSender != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
}
// Print stats
s.mu.Lock()
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
s.mu.Unlock()
log.
Tag(tagManager).
Fields(log.Context{
"messages_published": messagesCount,
"messages_cached": messagesCached,
"topics_active": topicsCount,
"subscribers": subscribers,
"visitors": visitorsCount,
"emails_received": receivedMailTotal,
"emails_received_success": receivedMailSuccess,
"emails_received_failure": receivedMailFailure,
"emails_sent": sentMailTotal,
"emails_sent_success": sentMailSuccess,
"emails_sent_failure": sentMailFailure,
}).
Info("Server stats")
}
func (s *Server) pruneVisitors() {
staleVisitors := 0
log.
Tag(tagManager).
Timing(func() {
s.mu.Lock()
defer s.mu.Unlock()
for ip, v := range s.visitors {
if v.Stale() {
log.Tag(tagManager).With(v).Trace("Deleting stale visitor")
delete(s.visitors, ip)
staleVisitors++
}
}
}).
Field("stale_visitors", staleVisitors).
Debug("Deleted %d stale visitor(s)", staleVisitors)
}
func (s *Server) pruneTokens() {
if s.userManager != nil {
log.
Tag(tagManager).
Timing(func() {
if err := s.userManager.RemoveExpiredTokens(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error expiring user tokens")
}
if err := s.userManager.RemoveDeletedUsers(); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users")
}
}).
Debug("Removed expired tokens and users")
}
}
func (s *Server) pruneAttachments() {
if s.fileCache != nil {
log.
Tag(tagManager).
Timing(func() {
ids, err := s.messageCache.AttachmentsExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
} else if len(ids) > 0 {
if log.Tag(tagManager).IsDebug() {
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
}
if err := s.fileCache.Remove(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
}
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired attachments to delete")
}
}).
Debug("Deleted expired attachments")
}
}
func (s *Server) pruneMessages() {
log.
Tag(tagManager).
Timing(func() {
expiredMessageIDs, err := s.messageCache.MessagesExpired()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
} else if len(expiredMessageIDs) > 0 {
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
}
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
}
} else {
log.Tag(tagManager).Debug("No expired messages to delete")
}
}).
Debug("Pruned messages")
}

View File

@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"io" "io"
"net/http" "net/http"
@@ -147,7 +146,7 @@ func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse // writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse
func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error { func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error {
log.Debug("%s Matrix gateway error: %s", logHTTPPrefix(v, r), err.Error()) logvr(v, r).Tag(tagMatrix).Err(err).Debug("Matrix gateway error")
return writeMatrixResponse(w, err.pushKey) return writeMatrixResponse(w, err.pushKey)
} }

View File

@@ -1,9 +1,21 @@
package server package server
import ( import (
"heckel.io/ntfy/util"
"net/http" "net/http"
) )
func (s *Server) limitRequests(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
return next(w, r, v)
} else if !v.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests
}
return next(w, r, v)
}
}
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc { func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error { return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if !s.config.EnableWeb { if !s.config.EnableWeb {
@@ -24,7 +36,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc {
func (s *Server) ensureUser(next handleFunc) handleFunc { func (s *Server) ensureUser(next handleFunc) handleFunc {
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user == nil { if v.User() == nil {
return errHTTPUnauthorized return errHTTPUnauthorized
} }
return next(w, r, v) return next(w, r, v)
@@ -42,7 +54,7 @@ func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc { func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error { return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user.Billing.StripeCustomerID == "" { if v.User().Billing.StripeCustomerID == "" {
return errHTTPBadRequestNotAPaidUser return errHTTPBadRequestNotAPaidUser
} }
return next(w, r, v) return next(w, r, v)
@@ -51,9 +63,6 @@ func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
func (s *Server) withAccountSync(next handleFunc) handleFunc { func (s *Server) withAccountSync(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error { return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user == nil {
return next(w, r, v)
}
err := next(w, r, v) err := next(w, r, v)
if err == nil { if err == nil {
s.publishSyncEventAsync(v) s.publishSyncEventAsync(v)

View File

@@ -2,7 +2,6 @@ package server
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
@@ -21,12 +20,6 @@ import (
"time" "time"
) )
var (
errNotAPaidTier = errors.New("tier does not have billing price identifier")
errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
errNoBillingSubscription = errors.New("user does not have an active billing subscription")
)
// Payments in ntfy are done via Stripe. // Payments in ntfy are done via Stripe.
// //
// Pretty much all payments related things are in this file. The following processes // Pretty much all payments related things are in this file. The following processes
@@ -49,21 +42,32 @@ var (
// This is used to keep the local user database fields up to date. Stripe is the source of truth. // This is used to keep the local user database fields up to date. Stripe is the source of truth.
// What Stripe says is mirrored and not questioned. // What Stripe says is mirrored and not questioned.
var (
errNotAPaidTier = errors.New("tier does not have billing price identifier")
errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
errNoBillingSubscription = errors.New("user does not have an active billing subscription")
)
var (
retryUserDelays = []time.Duration{3 * time.Second, 5 * time.Second, 7 * time.Second}
)
// handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog // handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog
// in the UI. Note that this endpoint does NOT have a user context (no v.user!). // in the UI. Note that this endpoint does NOT have a user context (no u!).
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
tiers, err := s.userManager.Tiers() tiers, err := s.userManager.Tiers()
if err != nil { if err != nil {
return err return err
} }
freeTier := defaultVisitorLimits(s.config) freeTier := configBasedVisitorLimits(s.config)
response := []*apiAccountBillingTier{ response := []*apiAccountBillingTier{
{ {
// This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price. // This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price.
Limits: &apiAccountLimits{ Limits: &apiAccountLimits{
Messages: freeTier.MessagesLimit, Basis: string(visitorLimitBasisIP),
MessagesExpiryDuration: int64(freeTier.MessagesExpiryDuration.Seconds()), Messages: freeTier.MessageLimit,
Emails: freeTier.EmailsLimit, MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
Emails: freeTier.EmailLimit,
Reservations: freeTier.ReservationsLimit, Reservations: freeTier.ReservationsLimit,
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
AttachmentFileSize: freeTier.AttachmentFileSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
@@ -85,10 +89,11 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
Name: tier.Name, Name: tier.Name,
Price: priceStr, Price: priceStr,
Limits: &apiAccountLimits{ Limits: &apiAccountLimits{
Messages: tier.MessagesLimit, Basis: string(visitorLimitBasisTier),
MessagesExpiryDuration: int64(tier.MessagesExpiryDuration.Seconds()), Messages: tier.MessageLimit,
Emails: tier.EmailsLimit, MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
Reservations: tier.ReservationsLimit, Emails: tier.EmailLimit,
Reservations: tier.ReservationLimit,
AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
AttachmentFileSize: tier.AttachmentFileSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit,
AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()), AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()),
@@ -101,10 +106,11 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier // handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier
// will be updated by a subsequent webhook from Stripe, once the subscription becomes active. // will be updated by a subsequent webhook from Stripe, once the subscription becomes active.
func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user.Billing.StripeSubscriptionID != "" { u := v.User()
if u.Billing.StripeSubscriptionID != "" {
return errHTTPBadRequestBillingSubscriptionExists return errHTTPBadRequestBillingSubscriptionExists
} }
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit) req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
return err return err
} }
@@ -114,11 +120,14 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
} else if tier.StripePriceID == "" { } else if tier.StripePriceID == "" {
return errNotAPaidTier return errNotAPaidTier
} }
log.Info("Stripe: No existing subscription, creating checkout flow") logvr(v, r).
With(tier).
Tag(tagStripe).
Info("Creating Stripe checkout flow")
var stripeCustomerID *string var stripeCustomerID *string
if v.user.Billing.StripeCustomerID != "" { if u.Billing.StripeCustomerID != "" {
stripeCustomerID = &v.user.Billing.StripeCustomerID stripeCustomerID = &u.Billing.StripeCustomerID
stripeCustomer, err := s.stripe.GetCustomer(v.user.Billing.StripeCustomerID) stripeCustomer, err := s.stripe.GetCustomer(u.Billing.StripeCustomerID)
if err != nil { if err != nil {
return err return err
} else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 { } else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 {
@@ -128,7 +137,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
params := &stripe.CheckoutSessionParams{ params := &stripe.CheckoutSessionParams{
Customer: stripeCustomerID, // A user may have previously deleted their subscription Customer: stripeCustomerID, // A user may have previously deleted their subscription
ClientReferenceID: &v.user.Name, ClientReferenceID: &u.ID,
SuccessURL: &successURL, SuccessURL: &successURL,
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
AllowPromotionCodes: stripe.Bool(true), AllowPromotionCodes: stripe.Bool(true),
@@ -138,9 +147,9 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
Quantity: stripe.Int64(1), Quantity: stripe.Int64(1),
}, },
}, },
/*AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{ AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
Enabled: stripe.Bool(true), Enabled: stripe.Bool(true),
},*/ },
} }
sess, err := s.stripe.NewCheckoutSession(params) sess, err := s.stripe.NewCheckoutSession(params)
if err != nil { if err != nil {
@@ -155,8 +164,8 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
// handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use // handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use
// the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first // the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first
// and only time we can map the local username with the Stripe customer ID. // and only time we can map the local username with the Stripe customer ID.
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, _ *visitor) error { func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {
// We don't have a v.user in this endpoint, only a userManager! // We don't have v.User() in this endpoint, only a userManager!
matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path) matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path)
if len(matches) != 2 { if len(matches) != 2 {
return errHTTPInternalErrorInvalidPath return errHTTPInternalErrorInvalidPath
@@ -178,11 +187,33 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
if err != nil { if err != nil {
return err return err
} }
u, err := s.userManager.User(sess.ClientReferenceID) u, err := s.userManager.UserByID(sess.ClientReferenceID)
if err != nil { if err != nil {
return err return err
} }
if err := s.updateSubscriptionAndTier(u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { v.SetUser(u)
logvr(v, r).
With(tier).
Tag(tagStripe).
Fields(log.Context{
"stripe_customer_id": sess.Customer.ID,
"stripe_subscription_id": sub.ID,
"stripe_subscription_status": string(sub.Status),
"stripe_subscription_paid_until": sub.CurrentPeriodEnd,
}).
Info("Stripe checkout flow succeeded, updating user tier and subscription")
customerParams := &stripe.CustomerParams{
Params: stripe.Params{
Metadata: map[string]string{
"user_id": u.ID,
"user_name": u.Name,
},
},
}
if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil {
return err
}
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
return err return err
} }
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
@@ -192,10 +223,11 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates // handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
// a user's tier accordingly. This endpoint only works if there is an existing subscription. // a user's tier accordingly. This endpoint only works if there is an existing subscription.
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user.Billing.StripeSubscriptionID == "" { u := v.User()
if u.Billing.StripeSubscriptionID == "" {
return errNoBillingSubscription return errNoBillingSubscription
} }
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit) req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil { if err != nil {
return err return err
} }
@@ -203,10 +235,20 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
if err != nil { if err != nil {
return err return err
} }
log.Info("Stripe: Changing tier and subscription to %s", tier.Code) logvr(v, r).
sub, err := s.stripe.GetSubscription(v.user.Billing.StripeSubscriptionID) Tag(tagStripe).
Fields(log.Context{
"new_tier_id": tier.ID,
"new_tier_name": tier.Name,
"new_tier_stripe_price_id": tier.StripePriceID,
// Other stripe_* fields filled by visitor context
}).
Info("Changing Stripe subscription and billing tier to %s/%s (price %s)", tier.ID, tier.Name, tier.StripePriceID)
sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID)
if err != nil { if err != nil {
return err return err
} else if sub.Items == nil || len(sub.Items.Data) != 1 {
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "no items, or more than one item")
} }
params := &stripe.SubscriptionParams{ params := &stripe.SubscriptionParams{
CancelAtPeriodEnd: stripe.Bool(false), CancelAtPeriodEnd: stripe.Bool(false),
@@ -226,13 +268,16 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
} }
// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user, // handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,
// and cancelling the Stripe subscription entirely // and cancelling the Stripe subscription entirely. Note that this does not actually change the tier.
// That is done by a webhook at the period end (in X days).
func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user.Billing.StripeSubscriptionID != "" { logvr(v, r).Tag(tagStripe).Info("Deleting Stripe subscription")
u := v.User()
if u.Billing.StripeSubscriptionID != "" {
params := &stripe.SubscriptionParams{ params := &stripe.SubscriptionParams{
CancelAtPeriodEnd: stripe.Bool(true), CancelAtPeriodEnd: stripe.Bool(true),
} }
_, err := s.stripe.UpdateSubscription(v.user.Billing.StripeSubscriptionID, params) _, err := s.stripe.UpdateSubscription(u.Billing.StripeSubscriptionID, params)
if err != nil { if err != nil {
return err return err
} }
@@ -243,11 +288,13 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r
// handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the // handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the
// redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription. // redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription.
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user.Billing.StripeCustomerID == "" { logvr(v, r).Tag(tagStripe).Info("Creating Stripe billing portal session")
u := v.User()
if u.Billing.StripeCustomerID == "" {
return errHTTPBadRequestNotAPaidUser return errHTTPBadRequestNotAPaidUser
} }
params := &stripe.BillingPortalSessionParams{ params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(v.user.Billing.StripeCustomerID), Customer: stripe.String(u.Billing.StripeCustomerID),
ReturnURL: stripe.String(s.config.BaseURL), ReturnURL: stripe.String(s.config.BaseURL),
} }
ps, err := s.stripe.NewPortalSession(params) ps, err := s.stripe.NewPortalSession(params)
@@ -262,8 +309,8 @@ func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter,
// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync // handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync
// with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the // with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the
// visitor (v) in this endpoint is the Stripe API, so we don't have v.user available. // visitor (v) in this endpoint is the Stripe API, so we don't have u available.
func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Request, _ *visitor) error { func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {
stripeSignature := r.Header.Get("Stripe-Signature") stripeSignature := r.Header.Get("Stripe-Signature")
if stripeSignature == "" { if stripeSignature == "" {
return errHTTPBadRequestBillingRequestInvalid return errHTTPBadRequestBillingRequestInvalid
@@ -280,89 +327,107 @@ func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Requ
} else if event.Data == nil || event.Data.Raw == nil { } else if event.Data == nil || event.Data.Raw == nil {
return errHTTPBadRequestBillingRequestInvalid return errHTTPBadRequestBillingRequestInvalid
} }
log.Info("Stripe: webhook event %s received", event.Type)
switch event.Type { switch event.Type {
case "customer.subscription.updated": case "customer.subscription.updated":
return s.handleAccountBillingWebhookSubscriptionUpdated(event.Data.Raw) return s.handleAccountBillingWebhookSubscriptionUpdated(r, v, event)
case "customer.subscription.deleted": case "customer.subscription.deleted":
return s.handleAccountBillingWebhookSubscriptionDeleted(event.Data.Raw) return s.handleAccountBillingWebhookSubscriptionDeleted(r, v, event)
default: default:
logvr(v, r).
Tag(tagStripe).
Field("stripe_webhook_type", event.Type).
Warn("Unhandled Stripe webhook event %s received", event.Type)
return nil return nil
} }
} }
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMessage) error { func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, v *visitor, event stripe.Event) error {
r, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event))) ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
if err != nil { if err != nil {
return err return err
} else if r.ID == "" || r.Customer == "" || r.Status == "" || r.CurrentPeriodEnd == 0 || r.Items == nil || len(r.Items.Data) != 1 || r.Items.Data[0].Price == nil || r.Items.Data[0].Price.ID == "" { } else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" {
return errHTTPBadRequestBillingRequestInvalid return errHTTPBadRequestBillingRequestInvalid
} }
subscriptionID, priceID := r.ID, r.Items.Data[0].Price.ID subscriptionID, priceID := ev.ID, ev.Items.Data[0].Price.ID
log.Info("Stripe: customer %s: Updating subscription to status %s, with price %s", r.Customer, r.Status, priceID) logvr(v, r).
u, err := s.userManager.UserByStripeCustomer(r.Customer) Tag(tagStripe).
Fields(log.Context{
"stripe_webhook_type": event.Type,
"stripe_customer_id": ev.Customer,
"stripe_subscription_id": ev.ID,
"stripe_subscription_status": ev.Status,
"stripe_subscription_paid_until": ev.CurrentPeriodEnd,
"stripe_subscription_cancel_at": ev.CancelAt,
"stripe_price_id": priceID,
}).
Info("Updating subscription to status %s, with price %s", ev.Status, priceID)
userFn := func() (*user.User, error) {
return s.userManager.UserByStripeCustomer(ev.Customer)
}
// We retry the user retrieval function, because during the Stripe checkout, there a race between the browser
// checkout success redirect (see handleAccountBillingSubscriptionCreateSuccess), and this webhook. The checkout
// success call is the one that updates the user with the Stripe customer ID.
u, err := util.Retry[user.User](userFn, retryUserDelays...)
if err != nil { if err != nil {
return err return err
} }
v.SetUser(u)
tier, err := s.userManager.TierByStripePrice(priceID) tier, err := s.userManager.TierByStripePrice(priceID)
if err != nil { if err != nil {
return err return err
} }
if err := s.updateSubscriptionAndTier(u, tier, r.Customer, subscriptionID, r.Status, r.CurrentPeriodEnd, r.CancelAt); err != nil { if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
return err return err
} }
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified())) s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
return nil return nil
} }
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMessage) error { func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request, v *visitor, event stripe.Event) error {
r, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event))) ev, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
if err != nil { if err != nil {
return err return err
} else if r.Customer == "" { } else if ev.Customer == "" {
return errHTTPBadRequestBillingRequestInvalid return errHTTPBadRequestBillingRequestInvalid
} }
log.Info("Stripe: customer %s: subscription deleted, downgrading to unpaid tier", r.Customer) u, err := s.userManager.UserByStripeCustomer(ev.Customer)
u, err := s.userManager.UserByStripeCustomer(r.Customer)
if err != nil { if err != nil {
return err return err
} }
if err := s.updateSubscriptionAndTier(u, nil, r.Customer, "", "", 0, 0); err != nil { v.SetUser(u)
logvr(v, r).
Tag(tagStripe).
Field("stripe_webhook_type", event.Type).
Info("Subscription deleted, downgrading to unpaid tier")
if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", 0, 0); err != nil {
return err return err
} }
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified())) s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
return nil return nil
} }
func (s *Server) updateSubscriptionAndTier(u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error { func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
// Remove excess reservations (if too many for tier), and mark associated messages deleted
reservations, err := s.userManager.Reservations(u.Name)
if err != nil {
return err
}
reservationsLimit := visitorDefaultReservationsLimit reservationsLimit := visitorDefaultReservationsLimit
if tier != nil { if tier != nil {
reservationsLimit = tier.ReservationsLimit reservationsLimit = tier.ReservationLimit
} }
if int64(len(reservations)) > reservationsLimit { if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, reservationsLimit); err != nil {
topics := make([]string, 0) return err
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
topics = append(topics, reservations[i].Topic)
}
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
return err
}
if err := s.messageCache.ExpireMessages(topics...); err != nil {
return err
}
} }
// Change or remove tier if tier == nil && u.Tier != nil {
if tier == nil { logvr(v, r).Tag(tagStripe).Info("Resetting tier for user %s", u.Name)
if err := s.userManager.ResetTier(u.Name); err != nil { if err := s.userManager.ResetTier(u.Name); err != nil {
return err return err
} }
} else { } else if tier != nil && u.TierID() != tier.ID {
logvr(v, r).
Tag(tagStripe).
Fields(log.Context{
"new_tier_id": tier.ID,
"new_tier_name": tier.Name,
"new_tier_stripe_price_id": tier.StripePriceID,
}).
Info("Changing tier to tier %s (%s) for user %s", tier.ID, tier.Name, u.Name)
if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil { if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil {
return err return err
} }
@@ -410,6 +475,7 @@ type stripeAPI interface {
GetCustomer(id string) (*stripe.Customer, error) GetCustomer(id string) (*stripe.Customer, error)
GetSession(id string) (*stripe.CheckoutSession, error) GetSession(id string) (*stripe.CheckoutSession, error)
GetSubscription(id string) (*stripe.Subscription, error) GetSubscription(id string) (*stripe.Subscription, error)
UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error)
UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error)
CancelSubscription(id string) (*stripe.Subscription, error) CancelSubscription(id string) (*stripe.Subscription, error)
ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)
@@ -456,6 +522,10 @@ func (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
return subscription.Get(id, nil) return subscription.Get(id, nil)
} }
func (s *realStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {
return customer.Update(id, params)
}
func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) { func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
return subscription.Update(id, params) return subscription.Update(id, params)
} }

View File

@@ -5,15 +5,120 @@ import (
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"golang.org/x/time/rate"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"io" "io"
"net/netip"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
func TestPayments_Tiers(t *testing.T) {
stripeMock := &testStripeAPI{}
defer stripeMock.AssertExpectations(t)
c := newTestConfigWithAuthFile(t)
c.StripeSecretKey = "secret key"
c.StripeWebhookKey = "webhook key"
c.VisitorRequestLimitReplenish = 12 * time.Hour
c.CacheDuration = 13 * time.Hour
c.AttachmentFileSizeLimit = 111
c.VisitorAttachmentTotalSizeLimit = 222
c.AttachmentExpiryDuration = 123 * time.Second
s := newTestServer(t, c)
s.stripe = stripeMock
// Define how the mock should react
stripeMock.
On("ListPrices", mock.Anything).
Return([]*stripe.Price{
{ID: "price_123", UnitAmount: 500},
{ID: "price_456", UnitAmount: 1000},
{ID: "price_999", UnitAmount: 9999},
}, nil)
// Create tiers
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_1",
Code: "admin",
Name: "Admin",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
Name: "Pro",
MessageLimit: 1000,
MessageExpiryDuration: time.Hour,
EmailLimit: 123,
ReservationLimit: 777,
AttachmentFileSizeLimit: 999,
AttachmentTotalSizeLimit: 888,
AttachmentExpiryDuration: time.Minute,
StripePriceID: "price_123",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_444",
Code: "business",
Name: "Business",
MessageLimit: 2000,
MessageExpiryDuration: 10 * time.Hour,
EmailLimit: 123123,
ReservationLimit: 777333,
AttachmentFileSizeLimit: 999111,
AttachmentTotalSizeLimit: 888111,
AttachmentExpiryDuration: time.Hour,
StripePriceID: "price_456",
}))
response := request(t, s, "GET", "/v1/tiers", "", nil)
require.Equal(t, 200, response.Code)
var tiers []apiAccountBillingTier
require.Nil(t, json.NewDecoder(response.Body).Decode(&tiers))
require.Equal(t, 3, len(tiers))
// Free tier
tier := tiers[0]
require.Equal(t, "", tier.Code)
require.Equal(t, "", tier.Name)
require.Equal(t, "ip", tier.Limits.Basis)
require.Equal(t, int64(0), tier.Limits.Reservations)
require.Equal(t, int64(2), tier.Limits.Messages) // :-(
require.Equal(t, int64(13*3600), tier.Limits.MessagesExpiryDuration)
require.Equal(t, int64(24), tier.Limits.Emails)
require.Equal(t, int64(111), tier.Limits.AttachmentFileSize)
require.Equal(t, int64(222), tier.Limits.AttachmentTotalSize)
require.Equal(t, int64(123), tier.Limits.AttachmentExpiryDuration)
// Admin tier is not included, because it is not paid!
tier = tiers[1]
require.Equal(t, "pro", tier.Code)
require.Equal(t, "Pro", tier.Name)
require.Equal(t, "tier", tier.Limits.Basis)
require.Equal(t, int64(777), tier.Limits.Reservations)
require.Equal(t, int64(1000), tier.Limits.Messages)
require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration)
require.Equal(t, int64(123), tier.Limits.Emails)
require.Equal(t, int64(999), tier.Limits.AttachmentFileSize)
require.Equal(t, int64(888), tier.Limits.AttachmentTotalSize)
require.Equal(t, int64(60), tier.Limits.AttachmentExpiryDuration)
tier = tiers[2]
require.Equal(t, "business", tier.Code)
require.Equal(t, "Business", tier.Name)
require.Equal(t, "tier", tier.Limits.Basis)
require.Equal(t, int64(777333), tier.Limits.Reservations)
require.Equal(t, int64(2000), tier.Limits.Messages)
require.Equal(t, int64(36000), tier.Limits.MessagesExpiryDuration)
require.Equal(t, int64(123123), tier.Limits.Emails)
require.Equal(t, int64(999111), tier.Limits.AttachmentFileSize)
require.Equal(t, int64(888111), tier.Limits.AttachmentTotalSize)
require.Equal(t, int64(3600), tier.Limits.AttachmentExpiryDuration)
}
func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) { func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
stripeMock := &testStripeAPI{} stripeMock := &testStripeAPI{}
defer stripeMock.AssertExpectations(t) defer stripeMock.AssertExpectations(t)
@@ -30,11 +135,12 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
// Create tier and user // Create tier and user
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro", Code: "pro",
StripePriceID: "price_123", StripePriceID: "price_123",
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
// Create subscription // Create subscription
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{ response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
@@ -65,11 +171,12 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil) Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
// Create tier and user // Create tier and user
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro", Code: "pro",
StripePriceID: "price_123", StripePriceID: "price_123",
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
u, err := s.userManager.User("phil") u, err := s.userManager.User("phil")
require.Nil(t, err) require.Nil(t, err)
@@ -106,11 +213,12 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
Return(&stripe.Subscription{}, nil) Return(&stripe.Subscription{}, nil)
// Create tier and user // Create tier and user
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro", Code: "pro",
StripePriceID: "price_123", StripePriceID: "price_123",
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
u, err := s.userManager.User("phil") u, err := s.userManager.User("phil")
require.Nil(t, err) require.Nil(t, err)
@@ -122,7 +230,7 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing)) require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
// Delete account // Delete account
rr := request(t, s, "DELETE", "/v1/account", "", map[string]string{ rr := request(t, s, "DELETE", "/v1/account", `{"password": "phil"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
@@ -133,6 +241,164 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
require.Equal(t, 401, rr.Code) require.Equal(t, 401, rr.Code)
} }
func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *testing.T) {
// This test is too overloaded, but it's also a great end-to-end a test.
//
// It tests:
// - A successful checkout flow (not a paying customer -> paying customer)
// - Tier-changes reset the rate limits for the user
// - The request limits for tier-less user and a tier-user
// - The message limits for a tier-user
stripeMock := &testStripeAPI{}
defer stripeMock.AssertExpectations(t)
c := newTestConfigWithAuthFile(t)
c.StripeSecretKey = "secret key"
c.StripeWebhookKey = "webhook key"
c.VisitorRequestLimitBurst = 5
c.VisitorRequestLimitReplenish = time.Hour
c.CacheBatchSize = 500
c.CacheBatchTimeout = time.Second
s := newTestServer(t, c)
s.stripe = stripeMock
// Create a user with a Stripe subscription and 3 reservations
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "starter",
StripePriceID: "price_1234",
ReservationLimit: 1,
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
MessageExpiryDuration: time.Hour,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) // No tier
u, err := s.userManager.User("phil")
require.Nil(t, err)
// Define how the mock should react
stripeMock.
On("GetSession", "SOMETOKEN").
Return(&stripe.CheckoutSession{
ClientReferenceID: u.ID, // ntfy user ID
Customer: &stripe.Customer{
ID: "acct_5555",
},
Subscription: &stripe.Subscription{
ID: "sub_1234",
},
}, nil)
stripeMock.
On("GetSubscription", "sub_1234").
Return(&stripe.Subscription{
ID: "sub_1234",
Status: stripe.SubscriptionStatusActive,
CurrentPeriodEnd: 123456789,
CancelAt: 0,
Items: &stripe.SubscriptionItemList{
Data: []*stripe.SubscriptionItem{
{
Price: &stripe.Price{ID: "price_1234"},
},
},
},
}, nil)
stripeMock.
On("UpdateCustomer", "acct_5555", &stripe.CustomerParams{
Params: stripe.Params{
Metadata: map[string]string{
"user_id": u.ID,
"user_name": u.Name,
},
},
}).
Return(&stripe.Customer{}, nil)
// Send messages until rate limit of free tier is hit
for i := 0; i < 5; i++ {
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
}
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 429, rr.Code)
// Verify some "before-stats"
u, err = s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, u.Tier)
require.Equal(t, "", u.Billing.StripeCustomerID)
require.Equal(t, "", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
require.Equal(t, int64(0), u.Stats.Emails)
// Simulate Stripe success return URL call (no user context)
rr = request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
require.Equal(t, 303, rr.Code)
// Verify that database columns were updated
u, err = s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
require.Equal(t, int64(0), u.Stats.Messages)
require.Equal(t, int64(0), u.Stats.Emails)
// Now for the fun part: Verify that new rate limits are immediately applied
// This only tests the request limiter, which kicks in before the message limiter.
for i := 0; i < 11; i++ {
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
}
rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 429, rr.Code)
// Now let's test the message limiter by faking a ridiculously generous rate limiter
v := s.visitor(netip.MustParseAddr("9.9.9.9"), u)
v.requestLimiter = rate.NewLimiter(rate.Every(time.Millisecond), 1000000)
var wg sync.WaitGroup
for i := 0; i < 209; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code, "Failed on %d", i)
}(i)
}
wg.Wait()
rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 429, rr.Code)
// And now let's cross-check that the stats are correct too
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(220), account.Limits.Messages)
require.Equal(t, int64(220), account.Stats.Messages)
require.Equal(t, int64(0), account.Stats.MessagesRemaining)
}
func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) { func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) {
// This tests incoming webhooks from Stripe to update a subscription: // This tests incoming webhooks from Stripe to update a subscription:
// - All Stripe columns are updated in the user table // - All Stripe columns are updated in the user table
@@ -154,30 +420,34 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil) Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil)
// Create a user with a Stripe subscription and 3 reservations // Create a user with a Stripe subscription and 3 reservations
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_1",
Code: "starter", Code: "starter",
StripePriceID: "price_1234", // ! StripePriceID: "price_1234", // !
ReservationsLimit: 1, // ! ReservationLimit: 1, // !
MessagesLimit: 100, MessageLimit: 100,
MessagesExpiryDuration: time.Hour, MessageExpiryDuration: time.Hour,
AttachmentExpiryDuration: time.Hour, AttachmentExpiryDuration: time.Hour,
AttachmentFileSizeLimit: 1000000, AttachmentFileSizeLimit: 1000000,
AttachmentTotalSizeLimit: 1000000, AttachmentTotalSizeLimit: 1000000,
AttachmentBandwidthLimit: 1000000,
})) }))
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_2",
Code: "pro", Code: "pro",
StripePriceID: "price_1111", // ! StripePriceID: "price_1111", // !
ReservationsLimit: 3, // ! ReservationLimit: 3, // !
MessagesLimit: 200, MessageLimit: 200,
MessagesExpiryDuration: time.Hour, MessageExpiryDuration: time.Hour,
AttachmentExpiryDuration: time.Hour, AttachmentExpiryDuration: time.Hour,
AttachmentFileSizeLimit: 1000000, AttachmentFileSizeLimit: 1000000,
AttachmentTotalSizeLimit: 1000000, AttachmentTotalSizeLimit: 1000000,
AttachmentBandwidthLimit: 1000000,
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro")) require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.ReserveAccess("phil", "atopic", user.PermissionDenyAll)) require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.ReserveAccess("phil", "ztopic", user.PermissionDenyAll)) require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll))
// Add billing details // Add billing details
u, err := s.userManager.User("phil") u, err := s.userManager.User("phil")
@@ -254,10 +524,205 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID)) require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID))
} }
func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
// This tests incoming webhooks from Stripe to delete a subscription. It verifies that the database is
// updated (all Stripe fields are deleted, and the tier is removed).
//
// It doesn't fully test the message/attachment deletion. That is tested above in the subscription update call.
stripeMock := &testStripeAPI{}
defer stripeMock.AssertExpectations(t)
c := newTestConfigWithAuthFile(t)
c.StripeSecretKey = "secret key"
c.StripeWebhookKey = "webhook key"
s := newTestServer(t, c)
s.stripe = stripeMock
// Define how the mock should react
stripeMock.
On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key").
Return(jsonToStripeEvent(t, subscriptionDeletedEventJSON), nil)
// Create a user with a Stripe subscription and 3 reservations
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_1",
Code: "pro",
StripePriceID: "price_1234",
ReservationLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
// Add billing details
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
StripeCustomerID: "acct_5555",
StripeSubscriptionID: "sub_1234",
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
StripeSubscriptionPaidUntil: time.Unix(123, 0),
StripeSubscriptionCancelAt: time.Unix(0, 0),
}))
// Call the webhook: This does all the magic
rr := request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{
"Stripe-Signature": "stripe signature",
})
require.Equal(t, 200, rr.Code)
// Verify that database columns were updated
u, err = s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, u.Tier)
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
require.Equal(t, "", u.Billing.StripeSubscriptionID)
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
// Verify that reservations were deleted
r, err := s.userManager.Reservations("phil")
require.Nil(t, err)
require.Equal(t, 0, len(r))
}
func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
stripeMock := &testStripeAPI{}
defer stripeMock.AssertExpectations(t)
c := newTestConfigWithAuthFile(t)
c.StripeSecretKey = "secret key"
c.StripeWebhookKey = "webhook key"
s := newTestServer(t, c)
s.stripe = stripeMock
// Define how the mock should react
stripeMock.
On("GetSubscription", "sub_123").
Return(&stripe.Subscription{
ID: "sub_123",
Items: &stripe.SubscriptionItemList{
Data: []*stripe.SubscriptionItem{
{
ID: "someid_123",
Price: &stripe.Price{ID: "price_123"},
},
},
},
}, nil)
stripeMock.
On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{
CancelAtPeriodEnd: stripe.Bool(false),
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
Items: []*stripe.SubscriptionItemsParams{
{
ID: stripe.String("someid_123"),
Price: stripe.String("price_456"),
},
},
}).
Return(&stripe.Subscription{}, nil)
// Create tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123",
Code: "pro",
StripePriceID: "price_123",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_456",
Code: "business",
StripePriceID: "price_456",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",
}))
// Call endpoint to change subscription
rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
}
func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) {
stripeMock := &testStripeAPI{}
defer stripeMock.AssertExpectations(t)
c := newTestConfigWithAuthFile(t)
c.StripeSecretKey = "secret key"
c.StripeWebhookKey = "webhook key"
s := newTestServer(t, c)
s.stripe = stripeMock
// Define how the mock should react
stripeMock.
On("UpdateSubscription", "sub_123", mock.MatchedBy(func(s *stripe.SubscriptionParams) bool {
return *s.CancelAtPeriodEnd // Is true
})).
Return(&stripe.Subscription{}, nil)
// Create user
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",
}))
// Delete subscription
rr := request(t, s, "DELETE", "/v1/account/billing/subscription", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
}
func TestPayments_CreatePortalSession(t *testing.T) {
stripeMock := &testStripeAPI{}
defer stripeMock.AssertExpectations(t)
c := newTestConfigWithAuthFile(t)
c.StripeSecretKey = "secret key"
c.StripeWebhookKey = "webhook key"
s := newTestServer(t, c)
s.stripe = stripeMock
// Define how the mock should react
stripeMock.
On("NewPortalSession", &stripe.BillingPortalSessionParams{
Customer: stripe.String("acct_123"),
ReturnURL: stripe.String(s.config.BaseURL),
}).
Return(&stripe.BillingPortalSession{
URL: "https://billing.stripe.com/blablabla",
}, nil)
// Create user
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",
}))
// Create portal session
rr := request(t, s, "POST", "/v1/account/billing/portal", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
ps, _ := util.UnmarshalJSON[apiAccountBillingPortalRedirectResponse](io.NopCloser(rr.Body))
require.Equal(t, "https://billing.stripe.com/blablabla", ps.RedirectURL)
}
type testStripeAPI struct { type testStripeAPI struct {
mock.Mock mock.Mock
} }
var _ stripeAPI = (*testStripeAPI)(nil)
func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
args := s.Called(params) args := s.Called(params)
return args.Get(0).(*stripe.CheckoutSession), args.Error(1) return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
@@ -288,8 +753,13 @@ func (s *testStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
return args.Get(0).(*stripe.Subscription), args.Error(1) return args.Get(0).(*stripe.Subscription), args.Error(1)
} }
func (s *testStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {
args := s.Called(id, params)
return args.Get(0).(*stripe.Customer), args.Error(1)
}
func (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) { func (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
args := s.Called(id) args := s.Called(id, params)
return args.Get(0).(*stripe.Subscription), args.Error(1) return args.Get(0).(*stripe.Subscription), args.Error(1)
} }
@@ -303,8 +773,6 @@ func (s *testStripeAPI) ConstructWebhookEvent(payload []byte, header string, sec
return args.Get(0).(stripe.Event), args.Error(1) return args.Get(0).(stripe.Event), args.Error(1)
} }
var _ stripeAPI = (*testStripeAPI)(nil)
func jsonToStripeEvent(t *testing.T, v string) stripe.Event { func jsonToStripeEvent(t *testing.T, v string) stripe.Event {
var e stripe.Event var e stripe.Event
if err := json.Unmarshal([]byte(v), &e); err != nil { if err := json.Unmarshal([]byte(v), &e); err != nil {
@@ -335,3 +803,26 @@ const subscriptionUpdatedEventJSON = `
} }
} }
}` }`
const subscriptionDeletedEventJSON = `
{
"type": "customer.subscription.deleted",
"data": {
"object": {
"id": "sub_1234",
"customer": "acct_5555",
"status": "active",
"current_period_end": 1674268231,
"cancel_at": 1674299999,
"items": {
"data": [
{
"price": {
"id": "price_1234"
}
}
]
}
}
}
}`

View File

@@ -6,13 +6,14 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"io" "io"
"log"
"math/rand" "math/rand"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -22,9 +23,15 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
) )
func TestMain(m *testing.M) {
log.SetLevel(log.ErrorLevel)
os.Exit(m.Run())
}
func TestServer_PublishAndPoll(t *testing.T) { func TestServer_PublishAndPoll(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
@@ -122,6 +129,7 @@ func TestServer_PublishAndSubscribe(t *testing.T) {
publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil) publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil)
require.Equal(t, 200, publishFirstRR.Code) require.Equal(t, 200, publishFirstRR.Code)
time.Sleep(500 * time.Millisecond) // Publishing is done asynchronously, this avoids races
publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{ publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{
"Title": " This is a title ", "Title": " This is a title ",
@@ -150,6 +158,19 @@ func TestServer_PublishAndSubscribe(t *testing.T) {
require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags) require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags)
} }
func TestServer_Publish_Disallowed_Topic(t *testing.T) {
c := newTestConfig(t)
c.DisallowedTopics = []string{"about", "time", "this", "got", "added"}
s := newTestServer(t, c)
rr := request(t, s, "PUT", "/mytopic", "my first message", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s, "PUT", "/about", "another message", nil)
require.Equal(t, 400, rr.Code)
require.Equal(t, 40010, toHTTPError(t, rr.Body.String()).Code)
}
func TestServer_StaticSites(t *testing.T) { func TestServer_StaticSites(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
@@ -303,6 +324,18 @@ func TestServer_PublishAt(t *testing.T) {
require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though! require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though!
} }
func TestServer_PublishAt_Expires(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
"In": "2 days",
})
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.True(t, m.Expires > time.Now().Add(12*time.Hour+48*time.Hour-time.Minute).Unix())
require.True(t, m.Expires < time.Now().Add(12*time.Hour+48*time.Hour+time.Minute).Unix())
}
func TestServer_PublishAtWithCacheError(t *testing.T) { func TestServer_PublishAtWithCacheError(t *testing.T) {
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
@@ -625,7 +658,7 @@ func TestServer_Auth_Success_Admin(t *testing.T) {
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
s := newTestServer(t, c) s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
@@ -639,7 +672,7 @@ func TestServer_Auth_Success_User(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c) s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
@@ -653,7 +686,7 @@ func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c) s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite)) require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite)) require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite))
@@ -674,7 +707,7 @@ func TestServer_Auth_Fail_InvalidPass(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c) s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": util.BasicAuth("phil", "INVALID"), "Authorization": util.BasicAuth("phil", "INVALID"),
@@ -687,7 +720,7 @@ func TestServer_Auth_Fail_Unauthorized(t *testing.T) {
c.AuthDefault = user.PermissionDenyAll c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c) s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic! require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic!
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
@@ -701,7 +734,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
c.AuthDefault = user.PermissionReadWrite // Open by default c.AuthDefault = user.PermissionReadWrite // Open by default
s := newTestServer(t, c) s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll)) require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll))
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead)) require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
@@ -726,12 +759,30 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
require.Equal(t, 403, response.Code) // Anonymous read not allowed require.Equal(t, 403, response.Code) // Anonymous read not allowed
} }
func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) {
c := newTestConfigWithAuthFile(t)
s := newTestServer(t, c)
for i := 0; i < 10; i++ {
response := request(t, s, "PUT", "/announcements", "test", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 401, response.Code)
}
response := request(t, s, "PUT", "/announcements", "test", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 429, response.Code)
require.Equal(t, 42909, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Auth_ViaQuery(t *testing.T) { func TestServer_Auth_ViaQuery(t *testing.T) {
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c) s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin, "unit-test")) require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin))
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass")))) u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass"))))
response := request(t, s, "GET", u, "", nil) response := request(t, s, "GET", u, "", nil)
@@ -743,15 +794,31 @@ func TestServer_Auth_ViaQuery(t *testing.T) {
} }
func TestServer_StatsResetter(t *testing.T) { func TestServer_StatsResetter(t *testing.T) {
// This tests the stats resetter for
// - an anonymous user
// - a user without a tier (treated like the same as the anonymous user)
// - a user with a tier
c := newTestConfigWithAuthFile(t) c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
c.VisitorStatsResetTime = time.Now().Add(2 * time.Second) c.VisitorStatsResetTime = time.Now().Add(2 * time.Second)
s := newTestServer(t, c) s := newTestServer(t, c)
go s.runStatsResetter() go s.runStatsResetter()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) // Create user with tier (tieruser) and user without tier (phil)
require.Nil(t, s.userManager.AllowAccess("phil", "mytopic", user.PermissionReadWrite)) require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test",
MessageLimit: 5,
MessageExpiryDuration: -5 * time.Second, // Second, what a hack!
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddUser("tieruser", "tieruser", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("tieruser", "test"))
// Send an anonymous message
response := request(t, s, "PUT", "/mytopic", "test", nil)
require.Equal(t, 200, response.Code)
// Send messages from user without tier (phil)
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{ response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
@@ -759,30 +826,150 @@ func TestServer_StatsResetter(t *testing.T) {
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
} }
response := request(t, s, "GET", "/v1/account", "", map[string]string{ // Send messages from user with tier
"Authorization": util.BasicAuth("phil", "phil"), for i := 0; i < 2; i++ {
}) response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
require.Equal(t, 200, response.Code) "Authorization": util.BasicAuth("tieruser", "tieruser"),
})
require.Equal(t, 200, response.Code)
}
// User stats show 10 messages // User stats show 6 messages (for user without tier)
response = request(t, s, "GET", "/v1/account", "", map[string]string{ response = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(5), account.Stats.Messages) require.Equal(t, int64(6), account.Stats.Messages)
// User stats show 6 messages (for anonymous visitor)
response = request(t, s, "GET", "/v1/account", "", nil)
require.Equal(t, 200, response.Code)
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
require.Nil(t, err)
require.Equal(t, int64(6), account.Stats.Messages)
// User stats show 2 messages (for user with tier)
response = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("tieruser", "tieruser"),
})
require.Equal(t, 200, response.Code)
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
require.Nil(t, err)
require.Equal(t, int64(2), account.Stats.Messages)
// Wait for stats resetter to run // Wait for stats resetter to run
time.Sleep(2200 * time.Millisecond) time.Sleep(2200 * time.Millisecond)
// User stats show 0 messages now! // User stats show 0 messages now!
response = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
require.Nil(t, err)
require.Equal(t, int64(0), account.Stats.Messages)
// Since this is a user without a tier, the anonymous user should have the same stats
response = request(t, s, "GET", "/v1/account", "", nil) response = request(t, s, "GET", "/v1/account", "", nil)
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), account.Stats.Messages) require.Equal(t, int64(0), account.Stats.Messages)
// User stats show 0 messages (for user with tier)
response = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("tieruser", "tieruser"),
})
require.Equal(t, 200, response.Code)
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
require.Nil(t, err)
require.Equal(t, int64(0), account.Stats.Messages)
}
func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
// This tests that the messageLimiter (the only fixed limiter) and the emailsLimiter (token bucket)
// is reset by the stats resetter
c := newTestConfigWithAuthFile(t)
s := newTestServer(t, c)
s.smtpSender = &testMailer{}
// Publish some messages, and check stats
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", "test", nil)
require.Equal(t, 200, response.Code)
}
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
"Email": "test@email.com",
})
require.Equal(t, 200, response.Code)
rr := request(t, s, "GET", "/v1/account", "", nil)
require.Equal(t, 200, rr.Code)
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Nil(t, err)
require.Equal(t, int64(4), account.Stats.Messages)
require.Equal(t, int64(1), account.Stats.Emails)
v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil)
require.Equal(t, int64(4), v.Stats().Messages)
require.Equal(t, int64(4), v.messagesLimiter.Value())
require.Equal(t, int64(1), v.Stats().Emails)
require.Equal(t, int64(1), v.emailsLimiter.Value())
// Reset stats and check again
s.resetStats()
rr = request(t, s, "GET", "/v1/account", "", nil)
require.Equal(t, 200, rr.Code)
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Nil(t, err)
require.Equal(t, int64(0), account.Stats.Messages)
require.Equal(t, int64(0), account.Stats.Emails)
v = s.visitor(netip.MustParseAddr("9.9.9.9"), nil)
require.Equal(t, int64(0), v.Stats().Messages)
require.Equal(t, int64(0), v.messagesLimiter.Value())
require.Equal(t, int64(0), v.Stats().Emails)
require.Equal(t, int64(0), v.emailsLimiter.Value())
}
func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
// This tests that the daily message quota is prefilled originally from the database,
// if the visitor is unknown
c := newTestConfigWithAuthFile(t)
c.AuthStatsQueueWriterInterval = 100 * time.Millisecond
s := newTestServer(t, c)
// Create user, and update it with some message and email stats
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test",
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
s.userManager.EnqueueUserStats(u.ID, &user.Stats{
Messages: 123456,
Emails: 999,
})
time.Sleep(400 * time.Millisecond)
// Get account and verify stats are read from the DB, and that the visitor also has these stats
rr := request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Nil(t, err)
require.Equal(t, int64(123456), account.Stats.Messages)
require.Equal(t, int64(999), account.Stats.Emails)
v := s.visitor(netip.MustParseAddr("9.9.9.9"), u)
require.Equal(t, int64(123456), v.Stats().Messages)
require.Equal(t, int64(123456), v.messagesLimiter.Value())
require.Equal(t, int64(999), v.Stats().Emails)
require.Equal(t, int64(999), v.emailsLimiter.Value())
} }
type testMailer struct { type testMailer struct {
@@ -830,7 +1017,7 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) { func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.VisitorRequestLimitBurst = 60 c.VisitorRequestLimitBurst = 60
c.VisitorRequestLimitReplenish = 500 * time.Millisecond c.VisitorRequestLimitReplenish = time.Second
s := newTestServer(t, c) s := newTestServer(t, c)
for i := 0; i < 60; i++ { for i := 0; i < 60; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
@@ -839,7 +1026,7 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
response := request(t, s, "PUT", "/mytopic", "message", nil) response := request(t, s, "PUT", "/mytopic", "message", nil)
require.Equal(t, 429, response.Code) require.Equal(t, 429, response.Code)
time.Sleep(520 * time.Millisecond) time.Sleep(1020 * time.Millisecond)
response = request(t, s, "PUT", "/mytopic", "message", nil) response = request(t, s, "PUT", "/mytopic", "message", nil)
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
} }
@@ -1132,12 +1319,12 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
s := newTestServer(t, c) s := newTestServer(t, c)
// Create tier with certain limits // Create tier with certain limits
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test", Code: "test",
MessagesLimit: 5, MessageLimit: 5,
MessagesExpiryDuration: -5 * time.Second, // Second, what a hack! MessageExpiryDuration: -5 * time.Second, // Second, what a hack!
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test")) require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish to reach message limit // Publish to reach message limit
@@ -1164,7 +1351,7 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
} }
func TestServer_PublishAttachment(t *testing.T) { func TestServer_PublishAttachment(t *testing.T) {
content := util.RandomString(5000) // > 4096 content := "text file!" + util.RandomString(4990) // > 4096
s := newTestServer(t, newTestConfig(t)) s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", content, nil) response := request(t, s, "PUT", "/mytopic", content, nil)
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
@@ -1311,7 +1498,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
c.VisitorAttachmentTotalSizeLimit = 10000 c.VisitorAttachmentTotalSizeLimit = 10000
s := newTestServer(t, c) s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil) response := request(t, s, "PUT", "/mytopic", "text file!"+util.RandomString(4990), nil)
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
require.Equal(t, "You received a file: attachment.txt", msg.Message) require.Equal(t, "You received a file: attachment.txt", msg.Message)
@@ -1361,21 +1548,23 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
// Create tier with certain limits // Create tier with certain limits
sevenDays := time.Duration(604800) * time.Second sevenDays := time.Duration(604800) * time.Second
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test", Code: "test",
MessagesLimit: 10, MessageLimit: 10,
MessagesExpiryDuration: sevenDays, MessageExpiryDuration: sevenDays,
AttachmentFileSizeLimit: 50_000, AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000, AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: sevenDays, // 7 days AttachmentExpiryDuration: sevenDays, // 7 days
AttachmentBandwidthLimit: 100000,
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test")) require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish and make sure we can retrieve it // Publish and make sure we can retrieve it
response := request(t, s, "PUT", "/mytopic", content, map[string]string{ response := request(t, s, "PUT", "/mytopic", content, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String()) msg := toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix()) require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
@@ -1396,6 +1585,43 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
} }
func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) {
content := util.RandomString(5000) // > 4096
c := newTestConfigWithAuthFile(t)
c.VisitorAttachmentDailyBandwidthLimit = 1000 // Much lower than tier bandwidth!
s := newTestServer(t, c)
// Create tier with certain limits
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test",
MessageLimit: 10,
MessageExpiryDuration: time.Hour,
AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: time.Hour,
AttachmentBandwidthLimit: 14000, // < 3x5000 bytes -> enough for one upload, one download
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish and make sure we can retrieve it
rr := request(t, s, "PUT", "/mytopic", content, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
msg := toMessage(t, rr.Body.String())
// Retrieve it (first time succeeds)
rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) // File downloads do not send auth headers!!
require.Equal(t, 200, rr.Code)
require.Equal(t, content, rr.Body.String())
// Retrieve it AGAIN (fails, due to bandwidth limit)
rr = request(t, s, "GET", "/file/"+msg.ID, content, nil)
require.Equal(t, 429, rr.Code)
}
func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
smallFile := util.RandomString(20_000) smallFile := util.RandomString(20_000)
largeFile := util.RandomString(50_000) largeFile := util.RandomString(50_000)
@@ -1406,14 +1632,15 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
s := newTestServer(t, c) s := newTestServer(t, c)
// Create tier with certain limits // Create tier with certain limits
require.Nil(t, s.userManager.CreateTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test", Code: "test",
MessagesLimit: 100, MessageLimit: 100,
AttachmentFileSizeLimit: 50_000, AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000, AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: 30 * time.Second, AttachmentExpiryDuration: 30 * time.Second,
AttachmentBandwidthLimit: 1000000,
})) }))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test")) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.ChangeTier("phil", "test")) require.Nil(t, s.userManager.ChangeTier("phil", "test"))
// Publish small file as anonymous // Publish small file as anonymous
@@ -1499,6 +1726,25 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
require.Equal(t, 41301, err.Code) require.Equal(t, 41301, err.Code)
} }
func TestServer_PublishAttachmentAndImmediatelyGetItWithCacheTimeout(t *testing.T) {
// This tests the awkward util.Retry in handleFile: Due to the async persisting of messages,
// the message is not immediately available when attempting to download it.
c := newTestConfig(t)
c.CacheBatchTimeout = 500 * time.Millisecond
c.CacheBatchSize = 10
s := newTestServer(t, c)
content := "this is an ATTACHMENT"
rr := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil)
m := toMessage(t, rr.Body.String())
require.Equal(t, "myfile.txt", m.Attachment.Name)
path := strings.TrimPrefix(m.Attachment.URL, "http://127.0.0.1:12345")
rr = request(t, s, "GET", path, "", nil)
require.Equal(t, 200, rr.Code) // Not 404!
require.Equal(t, content, rr.Body.String())
}
func TestServer_PublishAttachmentAccountStats(t *testing.T) { func TestServer_PublishAttachmentAccountStats(t *testing.T) {
content := util.RandomString(4999) // > 4096 content := util.RandomString(4999) // > 4096
@@ -1531,7 +1777,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11" r.RemoteAddr = "8.9.10.11"
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
v, err := s.visitor(r) v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "8.9.10.11", v.ip.String()) require.Equal(t, "8.9.10.11", v.ip.String())
} }
@@ -1543,7 +1789,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11" r.RemoteAddr = "8.9.10.11"
r.Header.Set("X-Forwarded-For", "1.1.1.1") r.Header.Set("X-Forwarded-For", "1.1.1.1")
v, err := s.visitor(r) v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "1.1.1.1", v.ip.String()) require.Equal(t, "1.1.1.1", v.ip.String())
} }
@@ -1555,7 +1801,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11" r.RemoteAddr = "8.9.10.11"
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ") r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
v, err := s.visitor(r) v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "234.5.2.1", v.ip.String()) require.Equal(t, "234.5.2.1", v.ip.String())
} }
@@ -1568,7 +1814,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
s := newTestServer(t, c) s := newTestServer(t, c)
// Add lots of messages // Add lots of messages
log.Printf("Adding %d messages", count) log.Info("Adding %d messages", count)
start := time.Now() start := time.Now()
messages := make([]*message, 0) messages := make([]*message, 0)
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
@@ -1578,37 +1824,73 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
messages = append(messages, newDefaultMessage(topicID, "some message")) messages = append(messages, newDefaultMessage(topicID, "some message"))
} }
require.Nil(t, s.messageCache.addMessages(messages)) require.Nil(t, s.messageCache.addMessages(messages))
log.Printf("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond)) log.Info("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond))
// Update stats // Update stats
statsChan := make(chan bool) statsChan := make(chan bool)
go func() { go func() {
log.Printf("Updating stats") log.Info("Updating stats")
start := time.Now() start := time.Now()
s.execManager() s.execManager()
log.Printf("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond)) log.Info("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
statsChan <- true statsChan <- true
}() }()
time.Sleep(50 * time.Millisecond) // Make sure it starts first time.Sleep(50 * time.Millisecond) // Make sure it starts first
// Publish message (during stats update) // Publish message (during stats update)
log.Printf("Publishing message") log.Info("Publishing message")
start = time.Now() start = time.Now()
response := request(t, s, "PUT", "/mytopic", "some body", nil) response := request(t, s, "PUT", "/mytopic", "some body", nil)
m := toMessage(t, response.Body.String()) m := toMessage(t, response.Body.String())
assert.Equal(t, "some body", m.Message) assert.Equal(t, "some body", m.Message)
assert.True(t, time.Since(start) < 100*time.Millisecond) assert.True(t, time.Since(start) < 100*time.Millisecond)
log.Printf("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
// Wait for all goroutines // Wait for all goroutines
<-statsChan select {
log.Printf("Done: Waiting for all locks") case <-statsChan:
case <-time.After(10 * time.Second):
t.Fatal("Timed out waiting for Go routines")
}
log.Info("Done: Waiting for all locks")
}
func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
conf := newTestConfigWithAuthFile(t)
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create user without tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
// Publish a message (anonymous user)
rr := request(t, s, "POST", "/mytopic", "hi", nil)
require.Equal(t, 200, rr.Code)
// Publish a message (non-tier user)
rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// User stats (anonymous user)
rr = request(t, s, "GET", "/v1/account", "", nil)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(2), account.Stats.Messages)
// User stats (non-tier user)
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, int64(2), account.Stats.Messages)
} }
func newTestConfig(t *testing.T) *Config { func newTestConfig(t *testing.T) *Config {
conf := NewConfig() conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345" conf.BaseURL = "http://127.0.0.1:12345"
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
conf.AttachmentCacheDir = t.TempDir() conf.AttachmentCacheDir = t.TempDir()
return conf return conf
} }
@@ -1616,6 +1898,8 @@ func newTestConfig(t *testing.T) *Config {
func newTestConfigWithAuthFile(t *testing.T) *Config { func newTestConfigWithAuthFile(t *testing.T) *Config {
conf := newTestConfig(t) conf := newTestConfig(t)
conf.AuthFile = filepath.Join(t.TempDir(), "user.db") conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot
return conf return conf
} }
@@ -1653,11 +1937,11 @@ func subscribe(t *testing.T, s *Server, url string, rr *httptest.ResponseRecorde
done <- true done <- true
}() }()
cancelAndWaitForDone := func() { cancelAndWaitForDone := func() {
time.Sleep(100 * time.Millisecond) time.Sleep(200 * time.Millisecond)
cancel() cancel()
<-done <-done
} }
time.Sleep(100 * time.Millisecond) time.Sleep(200 * time.Millisecond)
return cancelAndWaitForDone return cancelAndWaitForDone
} }

View File

@@ -37,8 +37,18 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
return err return err
} }
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to) ev := logvm(v, m).
log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message) Tag(tagEmail).
Fields(log.Context{
"email_via": s.config.SMTPSenderAddr,
"email_user": s.config.SMTPSenderUser,
"email_to": to,
})
if ev.IsTrace() {
ev.Field("email_body", message).Trace("Sending email")
} else if ev.IsDebug() {
ev.Debug("Sending email")
}
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message)) return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
}) })
} }
@@ -54,7 +64,7 @@ func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if err != nil { if err != nil {
log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error()) logvm(v, m).Err(err).Debug("Sending mail failed")
s.failure++ s.failure++
} else { } else {
s.success++ s.success++

View File

@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"heckel.io/ntfy/log"
"io" "io"
"mime" "mime"
"mime/multipart" "mime/multipart"
@@ -34,6 +33,9 @@ type smtpBackend struct {
mu sync.Mutex mu sync.Mutex
} }
var _ smtp.Backend = (*smtpBackend)(nil)
var _ smtp.Session = (*smtpSession)(nil)
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend { func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
return &smtpBackend{ return &smtpBackend{
config: conf, config: conf,
@@ -41,14 +43,9 @@ func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Reques
} }
} }
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username) logem(conn).Debug("Incoming mail")
return &smtpSession{backend: b, state: state}, nil return &smtpSession{backend: b, conn: conn}, nil
}
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
return &smtpSession{backend: b, state: state}, nil
} }
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) { func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
@@ -60,23 +57,23 @@ func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
// smtpSession is returned after EHLO. // smtpSession is returned after EHLO.
type smtpSession struct { type smtpSession struct {
backend *smtpBackend backend *smtpBackend
state *smtp.ConnectionState conn *smtp.Conn
topic string topic string
mu sync.Mutex mu sync.Mutex
} }
func (s *smtpSession) AuthPlain(username, password string) error { func (s *smtpSession) AuthPlain(username, _ string) error {
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username) logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
return nil return nil
} }
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error { func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts) logem(s.conn).Field("smtp_mail_from", from).Debug("MAIL FROM: %s", from)
return nil return nil
} }
func (s *smtpSession) Rcpt(to string) error { func (s *smtpSession) Rcpt(to string) error {
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to) logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to)
return s.withFailCount(func() error { return s.withFailCount(func() error {
conf := s.backend.config conf := s.backend.config
addressList, err := mail.ParseAddressList(to) addressList, err := mail.ParseAddressList(to)
@@ -113,10 +110,11 @@ func (s *smtpSession) Data(r io.Reader) error {
if err != nil { if err != nil {
return err return err
} }
if log.IsTrace() { ev := logem(s.conn)
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b)) if ev.IsTrace() {
} else if log.IsDebug() { ev.Field("smtp_data", string(b)).Trace("DATA")
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b)) } else if ev.IsDebug() {
ev.Field("smtp_data_len", len(b)).Debug("DATA")
} }
msg, err := mail.ReadMessage(bytes.NewReader(b)) msg, err := mail.ReadMessage(bytes.NewReader(b))
if err != nil { if err != nil {
@@ -156,9 +154,9 @@ func (s *smtpSession) Data(r io.Reader) error {
func (s *smtpSession) publishMessage(m *message) error { func (s *smtpSession) publishMessage(m *message) error {
// Extract remote address (for rate limiting) // Extract remote address (for rate limiting)
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String()) remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
if err != nil { if err != nil {
remoteAddr = s.state.RemoteAddr.String() remoteAddr = s.conn.Conn().RemoteAddr().String()
} }
// Call HTTP handler with fake HTTP request // Call HTTP handler with fake HTTP request
@@ -198,7 +196,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
if err != nil { if err != nil {
// Almost all of these errors are parse errors, and user input errors. // Almost all of these errors are parse errors, and user input errors.
// We do not want to spam the log with WARN messages. // We do not want to spam the log with WARN messages.
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error()) logem(s.conn).Err(err).Debug("Incoming mail error")
s.backend.failure++ s.backend.failure++
} }
return err return err

View File

@@ -1,16 +1,23 @@
package server package server
import ( import (
"bufio"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"io"
"net" "net"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestSmtpBackend_Multipart(t *testing.T) { func TestSmtpBackend_Multipart(t *testing.T) {
email := `MIME-Version: 1.0 email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
MIME-Version: 1.0
Date: Tue, 28 Dec 2021 00:30:10 +0100 Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com> Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more Subject: and one more
@@ -28,20 +35,25 @@ Content-Type: text/html; charset="UTF-8"
<div dir="ltr">what&#39;s up<br clear="all"><div><br></div></div> <div dir="ltr">what&#39;s up<br clear="all"><div><br></div></div>
--000000000000f3320b05d42915c9--` --000000000000f3320b05d42915c9--
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { .
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "and one more", r.Header.Get("Title")) require.Equal(t, "and one more", r.Header.Get("Title"))
require.Equal(t, "what's up", readAll(t, r.Body)) require.Equal(t, "what's up", readAll(t, r.Body))
}) })
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) defer s.Close()
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) defer c.Close()
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
require.Nil(t, session.Data(strings.NewReader(email)))
} }
func TestSmtpBackend_MultipartNoBody(t *testing.T) { func TestSmtpBackend_MultipartNoBody(t *testing.T) {
email := `MIME-Version: 1.0 email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-emailtest@ntfy.sh
DATA
MIME-Version: 1.0
Date: Tue, 28 Dec 2021 01:33:34 +0100 Date: Tue, 28 Dec 2021 01:33:34 +0100
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com> Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
Subject: This email has a subject but no body Subject: This email has a subject but no body
@@ -59,20 +71,25 @@ Content-Type: text/html; charset="UTF-8"
<div dir="ltr"><br></div> <div dir="ltr"><br></div>
--000000000000bcf4a405d429f8d4--` --000000000000bcf4a405d429f8d4--
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { .
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/emailtest", r.URL.Path) require.Equal(t, "/emailtest", r.URL.Path)
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body)) require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
}) })
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) defer s.Close()
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) defer c.Close()
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh")) writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
require.Nil(t, session.Data(strings.NewReader(email)))
} }
func TestSmtpBackend_Plaintext(t *testing.T) { func TestSmtpBackend_Plaintext(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com> Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more Subject: and one more
From: Phil <phil@example.com> From: Phil <phil@example.com>
@@ -80,56 +97,68 @@ To: mytopic@ntfy.sh
Content-Type: text/plain; charset="UTF-8" Content-Type: text/plain; charset="UTF-8"
what's up what's up
.
` `
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "and one more", r.Header.Get("Title")) require.Equal(t, "and one more", r.Header.Get("Title"))
require.Equal(t, "what's up", readAll(t, r.Body)) require.Equal(t, "what's up", readAll(t, r.Body))
}) })
conf.SMTPServerAddrPrefix = "" conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) defer s.Close()
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) defer c.Close()
require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
require.Nil(t, session.Data(strings.NewReader(email)))
} }
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) { func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
email := `Subject: Very short mail email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: mytopic@ntfy.sh
DATA
Subject: Very short mail
what's up what's up
.
` `
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/mytopic", r.URL.Path) require.Equal(t, "/mytopic", r.URL.Path)
require.Equal(t, "Very short mail", r.Header.Get("Title")) require.Equal(t, "Very short mail", r.Header.Get("Title"))
require.Equal(t, "what's up", readAll(t, r.Body)) require.Equal(t, "what's up", readAll(t, r.Body))
}) })
conf.SMTPServerAddrPrefix = "" conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) defer s.Close()
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) defer c.Close()
require.Nil(t, session.Rcpt("mytopic@ntfy.sh")) writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
require.Nil(t, session.Data(strings.NewReader(email)))
} }
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) { func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?= Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
From: Phil <phil@example.com> From: Phil <phil@example.com>
To: ntfy-mytopic@ntfy.sh To: ntfy-mytopic@ntfy.sh
Content-Type: text/plain; charset="UTF-8" Content-Type: text/plain; charset="UTF-8"
what's up what's up
.
` `
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title")) require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
}) })
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) defer s.Close()
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) defer c.Close()
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh")) writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
require.Nil(t, session.Data(strings.NewReader(email)))
} }
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) { func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com> Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more Subject: and one more
From: Phil <phil@example.com> From: Phil <phil@example.com>
@@ -148,60 +177,61 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
that should do it that should do it
.
` `
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) { s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
expected := `you know this is a string. expected := `you know this is a string.
it's a long string. it's a long string.
it's supposed to be longer than the max message length it's supposed to be longer than the max message length
@@ -214,68 +244,71 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
...................................................................... pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
BBBBBBBBBBBBBBBBBBBBBBBBB` BBBBBBBBBBBBBBBBBBBBBBBBB`
require.Equal(t, 4096, len(expected)) // Sanity check require.Equal(t, 4096, len(expected)) // Sanity check
require.Equal(t, expected, readAll(t, r.Body)) require.Equal(t, expected, readAll(t, r.Body))
}) })
defer s.Close()
defer c.Close()
conf.SMTPServerAddrPrefix = "" conf.SMTPServerAddrPrefix = ""
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4")) writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Nil(t, session.Data(strings.NewReader(email)))
} }
func TestSmtpBackend_Unsupported(t *testing.T) { func TestSmtpBackend_Unsupported(t *testing.T) {
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100 email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com> Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
Subject: and one more Subject: and one more
From: Phil <phil@example.com> From: Phil <phil@example.com>
@@ -283,34 +316,89 @@ To: mytopic@ntfy.sh
Content-Type: text/SOMETHINGELSE Content-Type: text/SOMETHINGELSE
what's up what's up
.
` `
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) { s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
// Nothing. t.Fatal("This should not be called")
}) })
conf.SMTPServerAddrPrefix = "" defer s.Close()
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass") defer c.Close()
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{})) writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: unsupported content type")
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
} }
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) { func TestSmtpBackend_InvalidAddress(t *testing.T) {
conf := newTestConfig(t) email := `EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: unsupported@ntfy.sh
DATA
Date: Tue, 28 Dec 2021 00:30:10 +0100
Subject: and one more
From: Phil <phil@example.com>
To: mytopic@ntfy.sh
Content-Type: text/plain
what's up
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("This should not be called")
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address")
}
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
conf = newTestConfig(t)
conf.SMTPServerListen = ":25" conf.SMTPServerListen = ":25"
conf.SMTPServerDomain = "ntfy.sh" conf.SMTPServerDomain = "ntfy.sh"
conf.SMTPServerAddrPrefix = "ntfy-" conf.SMTPServerAddrPrefix = "ntfy-"
backend := newMailBackend(conf, handler) backend := newMailBackend(conf, handler)
return conf, backend l, err := net.Listen("tcp", "127.0.0.1:0")
}
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
ip, err := net.ResolveIPAddr("ip", remoteAddr)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return &smtp.ConnectionState{ s = smtp.NewServer(backend)
Hostname: "myhostname", s.Domain = conf.SMTPServerDomain
LocalAddr: ip, s.AllowInsecureAuth = true
RemoteAddr: ip, go func() {
require.Nil(t, s.Serve(l))
}()
c, err = net.Dial("tcp", l.Addr().String())
if err != nil {
t.Fatal(err)
} }
scanner = bufio.NewScanner(c)
return
}
func writeAndReadUntilLine(t *testing.T, email string, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
_, err := io.WriteString(conn, email)
require.Nil(t, err)
readUntilLine(t, conn, scanner, expectedLine)
}
func readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
cancelChan := make(chan bool)
go func() {
select {
case <-cancelChan:
case <-time.After(3 * time.Second):
conn.Close()
t.Error("Failed waiting for expected output")
}
}()
var output string
for scanner.Scan() {
text := scanner.Text()
if strings.TrimSpace(text) == expectedLine {
cancelChan <- true
return
}
output += text + "\n"
//fmt.Println(text)
}
t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output)
} }

View File

@@ -10,10 +10,16 @@ import (
// can publish a message // can publish a message
type topic struct { type topic struct {
ID string ID string
subscribers map[int]subscriber subscribers map[int]*topicSubscriber
mu sync.Mutex mu sync.Mutex
} }
type topicSubscriber struct {
userID string // User ID associated with this subscription, may be empty
subscriber subscriber
cancel func()
}
// subscriber is a function that is called for every new message on a topic // 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 *message) error
@@ -21,16 +27,20 @@ type subscriber func(v *visitor, msg *message) error
func newTopic(id string) *topic { func newTopic(id string) *topic {
return &topic{ return &topic{
ID: id, ID: id,
subscribers: make(map[int]subscriber), subscribers: make(map[int]*topicSubscriber),
} }
} }
// Subscribe subscribes to this topic // Subscribe subscribes to this topic
func (t *topic) Subscribe(s subscriber) int { func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
subscriberID := rand.Int() subscriberID := rand.Int()
t.subscribers[subscriberID] = s t.subscribers[subscriberID] = &topicSubscriber{
userID: userID, // May be empty
subscriber: s,
cancel: cancel,
}
return subscriberID return subscriberID
} }
@@ -48,18 +58,18 @@ func (t *topic) Publish(v *visitor, m *message) error {
// subscribers map here. Actually sending out the messages then doesn't have to lock. // subscribers map here. Actually sending out the messages then doesn't have to lock.
subscribers := t.subscribersCopy() subscribers := t.subscribersCopy()
if len(subscribers) > 0 { if len(subscribers) > 0 {
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(subscribers)) logvm(v, m).Tag(tagPublish).Debug("Forwarding to %d subscriber(s)", len(subscribers))
for _, s := range subscribers { for _, s := range subscribers {
// We call the subscriber functions in their own Go routines because they are blocking, and // We call the subscriber functions in their own Go routines because they are blocking, and
// we don't want individual slow subscribers to be able to block others. // we don't want individual slow subscribers to be able to block others.
go func(s subscriber) { go func(s subscriber) {
if err := s(v, m); err != nil { if err := s(v, m); err != nil {
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m)) logvm(v, m).Tag(tagPublish).Err(err).Warn("Error forwarding to subscriber")
} }
}(s) }(s.subscriber)
} }
} else { } else {
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m)) logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding")
} }
}() }()
return nil return nil
@@ -72,13 +82,29 @@ func (t *topic) SubscribersCount() int {
return len(t.subscribers) return len(t.subscribers)
} }
// subscribersCopy returns a shallow copy of the subscribers map // CancelSubscribers calls the cancel function for all subscribers, forcing
func (t *topic) subscribersCopy() map[int]subscriber { func (t *topic) CancelSubscribers(exceptUserID string) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
subscribers := make(map[int]subscriber) for _, s := range t.subscribers {
for k, v := range t.subscribers { if s.userID != exceptUserID {
subscribers[k] = v log.Tag(tagSubscribe).Field("topic", t.ID).Debug("Canceling subscriber %s", s.userID)
s.cancel()
}
}
}
// subscribersCopy returns a shallow copy of the subscribers map
func (t *topic) subscribersCopy() map[int]*topicSubscriber {
t.mu.Lock()
defer t.mu.Unlock()
subscribers := make(map[int]*topicSubscriber)
for k, sub := range t.subscribers {
subscribers[k] = &topicSubscriber{
userID: sub.userID,
subscriber: sub.subscriber,
cancel: sub.cancel,
}
} }
return subscribers return subscribers
} }

View File

@@ -1,6 +1,7 @@
package server package server
import ( import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"net/http" "net/http"
"net/netip" "net/netip"
@@ -42,6 +43,23 @@ type message struct {
User string `json:"-"` // Username of the uploader, used to associated attachments User string `json:"-"` // Username of the uploader, used to associated attachments
} }
func (m *message) Context() log.Context {
fields := map[string]any{
"message_id": m.ID,
"message_time": m.Time,
"message_event": m.Event,
"message_topic": m.Topic,
"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 { type attachment struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
@@ -227,12 +245,31 @@ type apiAccountCreateRequest struct {
} }
type apiAccountPasswordChangeRequest struct { type apiAccountPasswordChangeRequest struct {
Password string `json:"password"`
NewPassword string `json:"new_password"`
}
type apiAccountDeleteRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
type apiAccountTokenIssueRequest struct {
Label *string `json:"label"`
Expires *int64 `json:"expires"` // Unix timestamp
}
type apiAccountTokenUpdateRequest struct {
Token string `json:"token"`
Label *string `json:"label"`
Expires *int64 `json:"expires"` // Unix timestamp
}
type apiAccountTokenResponse struct { type apiAccountTokenResponse struct {
Token string `json:"token"` Token string `json:"token"`
Expires int64 `json:"expires"` Label string `json:"label,omitempty"`
LastAccess int64 `json:"last_access,omitempty"`
LastOrigin string `json:"last_origin,omitempty"`
Expires int64 `json:"expires,omitempty"` // Unix timestamp
} }
type apiAccountTier struct { type apiAccountTier struct {
@@ -241,7 +278,7 @@ type apiAccountTier struct {
} }
type apiAccountLimits struct { type apiAccountLimits struct {
Basis string `json:"basis,omitempty"` // "ip", "role" or "tier" Basis string `json:"basis,omitempty"` // "ip" or "tier"
Messages int64 `json:"messages"` Messages int64 `json:"messages"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
Emails int64 `json:"emails"` Emails int64 `json:"emails"`
@@ -249,6 +286,7 @@ type apiAccountLimits struct {
AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentTotalSize int64 `json:"attachment_total_size"`
AttachmentFileSize int64 `json:"attachment_file_size"` AttachmentFileSize int64 `json:"attachment_file_size"`
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"` AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
AttachmentBandwidth int64 `json:"attachment_bandwidth"`
} }
type apiAccountStats struct { type apiAccountStats struct {
@@ -276,17 +314,18 @@ type apiAccountBilling struct {
} }
type apiAccountResponse struct { type apiAccountResponse struct {
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role,omitempty"` Role string `json:"role,omitempty"`
SyncTopic string `json:"sync_topic,omitempty"` SyncTopic string `json:"sync_topic,omitempty"`
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
Notification *user.NotificationPrefs `json:"notification,omitempty"` Notification *user.NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"` Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"`
Billing *apiAccountBilling `json:"billing,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"`
Billing *apiAccountBilling `json:"billing,omitempty"`
} }
type apiAccountReservationRequest struct { type apiAccountReservationRequest struct {
@@ -353,5 +392,6 @@ type apiStripeSubscriptionUpdatedEvent struct {
} }
type apiStripeSubscriptionDeletedEvent struct { type apiStripeSubscriptionDeletedEvent struct {
ID string `json:"id"`
Customer string `json:"customer"` Customer string `json:"customer"`
} }

View File

@@ -1,15 +1,14 @@
package server package server
import ( import (
"fmt" "bufio"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
"io" "io"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"strings" "strings"
"unicode/utf8" "sync"
) )
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
@@ -48,51 +47,6 @@ func readQueryParam(r *http.Request, names ...string) string {
return "" return ""
} }
func logMessagePrefix(v *visitor, m *message) string {
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
}
func logHTTPPrefix(v *visitor, r *http.Request) string {
requestURI := r.RequestURI
if requestURI == "" {
requestURI = r.URL.Path
}
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
}
func logSMTPPrefix(state *smtp.ConnectionState) string {
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
}
func renderHTTPRequest(r *http.Request) string {
peekLimit := 4096
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
for key, values := range r.Header {
for _, value := range values {
lines += fmt.Sprintf("%s: %s\n", key, value)
}
}
lines += "\n"
body, err := util.Peek(r.Body, peekLimit)
if err != nil {
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
} else if utf8.Valid(body.PeekedBytes) {
lines += string(body.PeekedBytes)
if body.LimitReached {
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
}
lines += "\n"
} else {
if body.LimitReached {
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
} else {
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
}
}
r.Body = body // Important: Reset body, so it can be re-read
return strings.TrimSpace(lines)
}
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr { func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
remoteAddr := r.RemoteAddr remoteAddr := r.RemoteAddr
addrPort, err := netip.ParseAddrPort(remoteAddr) addrPort, err := netip.ParseAddrPort(remoteAddr)
@@ -103,7 +57,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
if err != nil { if err != nil {
ip = netip.IPv4Unspecified() ip = netip.IPv4Unspecified()
if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used
log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err) logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr)
} }
} }
} }
@@ -114,7 +68,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",") ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr))) realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
if err != nil { if err != nil {
log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error()) logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip)
// Fall back to regular remote address if X-Forwarded-For is damaged // Fall back to regular remote address if X-Forwarded-For is damaged
} else { } else {
ip = realIP ip = realIP
@@ -123,8 +77,8 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
return ip return ip
} }
func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) { func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
obj, err := util.UnmarshalJSONWithLimit[T](r, limit) obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
if err == util.ErrUnmarshalJSON { if err == util.ErrUnmarshalJSON {
return nil, errHTTPBadRequestJSONInvalid return nil, errHTTPBadRequestJSONInvalid
} else if err == util.ErrTooLargeJSON { } else if err == util.ErrTooLargeJSON {
@@ -134,3 +88,57 @@ func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
} }
return obj, nil return obj, nil
} }
type httpResponseWriter struct {
w http.ResponseWriter
headerWritten bool
mu sync.Mutex
}
type httpResponseWriterWithHijacker struct {
httpResponseWriter
}
var _ http.ResponseWriter = (*httpResponseWriter)(nil)
var _ http.Flusher = (*httpResponseWriter)(nil)
var _ http.Hijacker = (*httpResponseWriterWithHijacker)(nil)
func newHTTPResponseWriter(w http.ResponseWriter) http.ResponseWriter {
if _, ok := w.(http.Hijacker); ok {
return &httpResponseWriterWithHijacker{httpResponseWriter: httpResponseWriter{w: w}}
}
return &httpResponseWriter{w: w}
}
func (w *httpResponseWriter) Header() http.Header {
return w.w.Header()
}
func (w *httpResponseWriter) Write(bytes []byte) (int, error) {
w.mu.Lock()
w.headerWritten = true
w.mu.Unlock()
return w.w.Write(bytes)
}
func (w *httpResponseWriter) WriteHeader(statusCode int) {
w.mu.Lock()
if w.headerWritten {
w.mu.Unlock()
return
}
w.headerWritten = true
w.mu.Unlock()
w.w.WriteHeader(statusCode)
}
func (w *httpResponseWriter) Flush() {
if f, ok := w.w.(http.Flusher); ok {
f.Flush()
}
}
func (w *httpResponseWriterWithHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h, _ := w.w.(http.Hijacker)
return h.Hijack()
}

View File

@@ -1,7 +1,8 @@
package server package server
import ( import (
"errors" "fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user" "heckel.io/ntfy/user"
"net/netip" "net/netip"
"sync" "sync"
@@ -12,38 +13,56 @@ import (
) )
const ( const (
// oneDay is an approximation of a day as a time.Duration
oneDay = 24 * time.Hour
// visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number // visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number
// has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since // has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since
// they are replenished faster (typically). // they are replenished faster (typically).
visitorExpungeAfter = 24 * time.Hour visitorExpungeAfter = oneDay
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve. // visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise // This number is zero, and changing it may have unintended consequences in the web app, or otherwise
visitorDefaultReservationsLimit = int64(0) visitorDefaultReservationsLimit = int64(0)
) )
var ( // Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
errVisitorLimitReached = errors.New("limit reached") // values (token bucket). This is only used to increase the values in server.yml, never decrease them.
//
// Example: Assuming a user.Tier's MessageLimit is 10,000:
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
// - the replenish rate is 2 * 10,000 / 24 hours
const (
visitorMessageToRequestLimitBurstRate = 0.05
visitorMessageToRequestLimitBurstMax = 1000
visitorMessageToRequestLimitReplenishFactor = 2
)
// Constants used to convert a tier-user's EmailLimit (see user.Tier) into adequate email limiter
// values (token bucket). Example: Assuming a user.Tier's EmailLimit is 200, the allowed burst is
// 40 (= 200 * 20%), which is <150 (the max).
const (
visitorEmailLimitBurstRate = 0.2
visitorEmailLimitBurstMax = 150
) )
// visitor represents an API user, and its associated rate.Limiter used for rate limiting // visitor represents an API user, and its associated rate.Limiter used for rate limiting
type visitor struct { type visitor struct {
config *Config config *Config
messageCache *messageCache messageCache *messageCache
userManager *user.Manager // May be nil! userManager *user.Manager // May be nil
ip netip.Addr ip netip.Addr // Visitor IP address
user *user.User user *user.User // Only set if authenticated user, otherwise nil
messages int64 // Number of messages sent, reset every day requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
emails int64 // Number of emails sent, reset every day messagesLimiter *util.FixedLimiter // Rate limiter for messages
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) emailsLimiter *util.RateLimiter // Rate limiter for emails
messagesLimiter util.Limiter // Rate limiter for messages, may be nil subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
emailsLimiter *rate.Limiter // Rate limiter for emails bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
subscriptionLimiter util.Limiter // Fixed limiter for active subscriptions (ongoing connections) accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
accountLimiter *rate.Limiter // Rate limiter for account creation firebase time.Time // Next allowed Firebase message
firebase time.Time // Next allowed Firebase message seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) mu sync.RWMutex
mu sync.Mutex
} }
type visitorInfo struct { type visitorInfo struct {
@@ -53,13 +72,18 @@ type visitorInfo struct {
type visitorLimits struct { type visitorLimits struct {
Basis visitorLimitBasis Basis visitorLimitBasis
MessagesLimit int64 RequestLimitBurst int
MessagesExpiryDuration time.Duration RequestLimitReplenish rate.Limit
EmailsLimit int64 MessageLimit int64
MessageExpiryDuration time.Duration
EmailLimit int64
EmailLimitBurst int
EmailLimitReplenish rate.Limit
ReservationsLimit int64 ReservationsLimit int64
AttachmentTotalSizeLimit int64 AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64 AttachmentFileSizeLimit int64
AttachmentExpiryDuration time.Duration AttachmentExpiryDuration time.Duration
AttachmentBandwidthLimit int64
} }
type visitorStats struct { type visitorStats struct {
@@ -83,56 +107,93 @@ const (
) )
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
var messagesLimiter util.Limiter
var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter
var messages, emails int64 var messages, emails int64
if user != nil { if user != nil {
messages = user.Stats.Messages messages = user.Stats.Messages
emails = user.Stats.Emails emails = user.Stats.Emails
} else {
accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst)
} }
if user != nil && user.Tier != nil { v := &visitor{
requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.MessagesLimit), conf.VisitorRequestLimitBurst)
messagesLimiter = util.NewFixedLimiter(user.Tier.MessagesLimit)
emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.EmailsLimit), conf.VisitorEmailLimitBurst)
} else {
requestLimiter = rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst)
emailsLimiter = rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst)
}
return &visitor{
config: conf, config: conf,
messageCache: messageCache, messageCache: messageCache,
userManager: userManager, // May be nil userManager: userManager, // May be nil
ip: ip, ip: ip,
user: user, user: user,
messages: messages,
emails: emails,
requestLimiter: requestLimiter,
messagesLimiter: messagesLimiter, // May be nil
emailsLimiter: emailsLimiter,
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
accountLimiter: accountLimiter, // May be nil
firebase: time.Unix(0, 0), firebase: time.Unix(0, 0),
seen: time.Now(), seen: time.Now(),
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
requestLimiter: nil, // Set in resetLimiters
messagesLimiter: nil, // Set in resetLimiters, may be nil
emailsLimiter: nil, // Set in resetLimiters
bandwidthLimiter: nil, // Set in resetLimiters
accountLimiter: nil, // Set in resetLimiters, may be nil
authLimiter: nil, // Set in resetLimiters, may be nil
} }
v.resetLimitersNoLock(messages, emails, false)
return v
} }
func (v *visitor) RequestAllowed() error { func (v *visitor) Context() log.Context {
if !v.requestLimiter.Allow() { v.mu.RLock()
return errVisitorLimitReached defer v.mu.RUnlock()
} return v.contextNoLock()
return nil
} }
func (v *visitor) FirebaseAllowed() error { func (v *visitor) contextNoLock() log.Context {
v.mu.Lock() info := v.infoLightNoLock()
defer v.mu.Unlock() fields := log.Context{
if time.Now().Before(v.firebase) { "visitor_ip": v.ip.String(),
return errVisitorLimitReached "visitor_messages": info.Stats.Messages,
"visitor_messages_limit": info.Limits.MessageLimit,
"visitor_messages_remaining": info.Stats.MessagesRemaining,
"visitor_emails": info.Stats.Emails,
"visitor_emails_limit": info.Limits.EmailLimit,
"visitor_emails_remaining": info.Stats.EmailsRemaining,
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
} }
return nil if v.authLimiter != nil {
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
}
if v.user != nil {
fields["user_id"] = v.user.ID
fields["user_name"] = v.user.Name
if v.user.Tier != nil {
for field, value := range v.user.Tier.Context() {
fields[field] = value
}
}
if v.user.Billing.StripeCustomerID != "" {
fields["stripe_customer_id"] = v.user.Billing.StripeCustomerID
}
if v.user.Billing.StripeSubscriptionID != "" {
fields["stripe_subscription_id"] = v.user.Billing.StripeSubscriptionID
}
}
return fields
}
func visitorExtendedInfoContext(info *visitorInfo) log.Context {
return log.Context{
"visitor_reservations": info.Stats.Reservations,
"visitor_reservations_limit": info.Limits.ReservationsLimit,
"visitor_reservations_remaining": info.Stats.ReservationsRemaining,
"visitor_attachment_total_size": info.Stats.AttachmentTotalSize,
"visitor_attachment_total_size_limit": info.Limits.AttachmentTotalSizeLimit,
"visitor_attachment_total_size_remaining": info.Stats.AttachmentTotalSizeRemaining,
}
}
func (v *visitor) RequestAllowed() bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
return v.requestLimiter.Allow()
}
func (v *visitor) FirebaseAllowed() bool {
v.mu.RLock()
defer v.mu.RUnlock()
return !time.Now().Before(v.firebase)
} }
func (v *visitor) FirebaseTemporarilyDeny() { func (v *visitor) FirebaseTemporarilyDeny() {
@@ -141,33 +202,72 @@ func (v *visitor) FirebaseTemporarilyDeny() {
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration) v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
} }
func (v *visitor) MessageAllowed() error { func (v *visitor) MessageAllowed() bool {
if v.messagesLimiter != nil && v.messagesLimiter.Allow(1) != nil { v.mu.RLock() // limiters could be replaced!
return errVisitorLimitReached defer v.mu.RUnlock()
} return v.messagesLimiter.Allow()
return nil
} }
func (v *visitor) EmailAllowed() error { func (v *visitor) EmailAllowed() bool {
if !v.emailsLimiter.Allow() { v.mu.RLock() // limiters could be replaced!
return errVisitorLimitReached defer v.mu.RUnlock()
} return v.emailsLimiter.Allow()
return nil
} }
func (v *visitor) SubscriptionAllowed() error { func (v *visitor) SubscriptionAllowed() bool {
v.mu.Lock() v.mu.RLock() // limiters could be replaced!
defer v.mu.Unlock() defer v.mu.RUnlock()
if err := v.subscriptionLimiter.Allow(1); err != nil { return v.subscriptionLimiter.Allow()
return errVisitorLimitReached }
// AuthAllowed returns true if an auth request can be attempted (> 1 token available)
func (v *visitor) AuthAllowed() bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
if v.authLimiter == nil {
return true
} }
return nil return v.authLimiter.Tokens() > 1
}
// AuthFailed records an auth failure
func (v *visitor) AuthFailed() {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
if v.authLimiter != nil {
v.authLimiter.Allow()
}
}
// AccountCreationAllowed returns true if a new account can be created
func (v *visitor) AccountCreationAllowed() bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
return false
}
return true
}
// AccountCreated decreases the account limiter. This is to be called after an account was created.
func (v *visitor) AccountCreated() {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
if v.accountLimiter != nil {
v.accountLimiter.Allow()
}
}
func (v *visitor) BandwidthAllowed(bytes int64) bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
return v.bandwidthLimiter.AllowN(bytes)
} }
func (v *visitor) RemoveSubscription() { func (v *visitor) RemoveSubscription() {
v.mu.Lock() v.mu.RLock()
defer v.mu.Unlock() defer v.mu.RUnlock()
v.subscriptionLimiter.Allow(-1) v.subscriptionLimiter.AllowN(-1)
} }
func (v *visitor) Keepalive() { func (v *visitor) Keepalive() {
@@ -177,101 +277,200 @@ func (v *visitor) Keepalive() {
} }
func (v *visitor) BandwidthLimiter() util.Limiter { func (v *visitor) BandwidthLimiter() util.Limiter {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
return v.bandwidthLimiter return v.bandwidthLimiter
} }
func (v *visitor) Stale() bool { func (v *visitor) Stale() bool {
v.mu.Lock() v.mu.RLock()
defer v.mu.Unlock() defer v.mu.RUnlock()
return time.Since(v.seen) > visitorExpungeAfter return time.Since(v.seen) > visitorExpungeAfter
} }
func (v *visitor) IncrementMessages() { func (v *visitor) Stats() *user.Stats {
v.mu.Lock() v.mu.RLock() // limiters could be replaced!
defer v.mu.Unlock() defer v.mu.RUnlock()
v.messages++ return &user.Stats{
if v.user != nil { Messages: v.messagesLimiter.Value(),
v.user.Stats.Messages = v.messages Emails: v.emailsLimiter.Value(),
}
}
func (v *visitor) IncrementEmails() {
v.mu.Lock()
defer v.mu.Unlock()
v.emails++
if v.user != nil {
v.user.Stats.Emails = v.emails
} }
} }
func (v *visitor) ResetStats() { func (v *visitor) ResetStats() {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
v.emailsLimiter.Reset()
v.messagesLimiter.Reset()
}
// User returns the visitor user, or nil if there is none
func (v *visitor) User() *user.User {
v.mu.RLock()
defer v.mu.RUnlock()
return v.user // May be nil
}
// IP returns the visitor IP address
func (v *visitor) IP() netip.Addr {
v.mu.RLock()
defer v.mu.RUnlock()
return v.ip
}
// Authenticated returns true if a user successfully authenticated
func (v *visitor) Authenticated() bool {
v.mu.RLock()
defer v.mu.RUnlock()
return v.user != nil
}
// SetUser sets the visitors user to the given value
func (v *visitor) SetUser(u *user.User) {
v.mu.Lock() v.mu.Lock()
defer v.mu.Unlock() defer v.mu.Unlock()
v.messages = 0 shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
v.emails = 0 v.user = u
if v.user != nil { if shouldResetLimiters {
v.user.Stats.Messages = 0 v.resetLimitersNoLock(u.Stats.Messages, u.Stats.Emails, true)
v.user.Stats.Emails = 0
// v.messagesLimiter = ... // FIXME
} }
} }
// MaybeUserID returns the user ID of the visitor (if any). If this is an anonymous visitor,
// an empty string is returned.
func (v *visitor) MaybeUserID() string {
v.mu.RLock()
defer v.mu.RUnlock()
if v.user != nil {
return v.user.ID
}
return ""
}
func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) {
limits := v.limitsNoLock()
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
if v.user == nil {
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
v.authLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAuthFailureLimitReplenish), v.config.VisitorAuthFailureLimitBurst)
} else {
v.accountLimiter = nil // Users cannot create accounts when logged in
v.authLimiter = nil // Users are already logged in, no need to limit requests
}
if enqueueUpdate && v.user != nil {
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
Messages: messages,
Emails: emails,
})
}
log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters
}
func (v *visitor) Limits() *visitorLimits { func (v *visitor) Limits() *visitorLimits {
v.mu.Lock() v.mu.RLock()
defer v.mu.Unlock() defer v.mu.RUnlock()
limits := defaultVisitorLimits(v.config) return v.limitsNoLock()
}
func (v *visitor) limitsNoLock() *visitorLimits {
if v.user != nil && v.user.Tier != nil { if v.user != nil && v.user.Tier != nil {
limits.Basis = visitorLimitBasisTier return tierBasedVisitorLimits(v.config, v.user.Tier)
limits.MessagesLimit = v.user.Tier.MessagesLimit }
limits.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration return configBasedVisitorLimits(v.config)
limits.EmailsLimit = v.user.Tier.EmailsLimit }
limits.ReservationsLimit = v.user.Tier.ReservationsLimit
limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit return &visitorLimits{
limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration Basis: visitorLimitBasisTier,
RequestLimitBurst: util.MinMax(int(float64(tier.MessageLimit)*visitorMessageToRequestLimitBurstRate), conf.VisitorRequestLimitBurst, visitorMessageToRequestLimitBurstMax),
RequestLimitReplenish: util.Max(rate.Every(conf.VisitorRequestLimitReplenish), dailyLimitToRate(tier.MessageLimit*visitorMessageToRequestLimitReplenishFactor)),
MessageLimit: tier.MessageLimit,
MessageExpiryDuration: tier.MessageExpiryDuration,
EmailLimit: tier.EmailLimit,
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
ReservationsLimit: tier.ReservationLimit,
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit,
AttachmentExpiryDuration: tier.AttachmentExpiryDuration,
AttachmentBandwidthLimit: tier.AttachmentBandwidthLimit,
}
}
func configBasedVisitorLimits(conf *Config) *visitorLimits {
messagesLimit := replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish) // Approximation!
if conf.VisitorMessageDailyLimit > 0 {
messagesLimit = int64(conf.VisitorMessageDailyLimit)
}
return &visitorLimits{
Basis: visitorLimitBasisIP,
RequestLimitBurst: conf.VisitorRequestLimitBurst,
RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish),
MessageLimit: messagesLimit,
MessageExpiryDuration: conf.CacheDuration,
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
EmailLimitBurst: conf.VisitorEmailLimitBurst,
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
ReservationsLimit: visitorDefaultReservationsLimit,
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
AttachmentExpiryDuration: conf.AttachmentExpiryDuration,
AttachmentBandwidthLimit: conf.VisitorAttachmentDailyBandwidthLimit,
} }
return limits
} }
func (v *visitor) Info() (*visitorInfo, error) { func (v *visitor) Info() (*visitorInfo, error) {
v.mu.Lock() v.mu.RLock()
messages := v.messages info := v.infoLightNoLock()
emails := v.emails v.mu.RUnlock()
v.mu.Unlock()
// Attachment stats from database
var attachmentsBytesUsed int64 var attachmentsBytesUsed int64
var err error var err error
if v.user != nil { u := v.User()
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name) if u != nil {
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(u.ID)
} else { } else {
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.ip.String()) attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.IP().String())
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
info.Stats.AttachmentTotalSize = attachmentsBytesUsed
info.Stats.AttachmentTotalSizeRemaining = zeroIfNegative(info.Limits.AttachmentTotalSizeLimit - attachmentsBytesUsed)
// Reservation stats from database
var reservations int64 var reservations int64
if v.user != nil && v.userManager != nil { if v.userManager != nil && u != nil {
reservations, err = v.userManager.ReservationsCount(v.user.Name) reservations, err = v.userManager.ReservationsCount(u.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
limits := v.Limits() info.Stats.Reservations = reservations
info.Stats.ReservationsRemaining = zeroIfNegative(info.Limits.ReservationsLimit - reservations)
return info, nil
}
func (v *visitor) infoLightNoLock() *visitorInfo {
messages := v.messagesLimiter.Value()
emails := v.emailsLimiter.Value()
limits := v.limitsNoLock()
stats := &visitorStats{ stats := &visitorStats{
Messages: messages, Messages: messages,
MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages), MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
Emails: emails, Emails: emails,
EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails), EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
Reservations: reservations,
ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations),
AttachmentTotalSize: attachmentsBytesUsed,
AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed),
} }
return &visitorInfo{ return &visitorInfo{
Limits: limits, Limits: limits,
Stats: stats, Stats: stats,
}, nil }
} }
func zeroIfNegative(value int64) int64 { func zeroIfNegative(value int64) int64 {
if value < 0 { if value < 0 {
return 0 return 0
@@ -280,22 +479,16 @@ func zeroIfNegative(value int64) int64 {
} }
func replenishDurationToDailyLimit(duration time.Duration) int64 { func replenishDurationToDailyLimit(duration time.Duration) int64 {
return int64(24 * time.Hour / duration) return int64(oneDay / duration)
} }
func dailyLimitToRate(limit int64) rate.Limit { func dailyLimitToRate(limit int64) rate.Limit {
return rate.Limit(limit) * rate.Every(24*time.Hour) return rate.Limit(limit) * rate.Every(oneDay)
} }
func defaultVisitorLimits(conf *Config) *visitorLimits { func visitorID(ip netip.Addr, u *user.User) string {
return &visitorLimits{ if u != nil && u.Tier != nil {
Basis: visitorLimitBasisIP, return fmt.Sprintf("user:%s", u.ID)
MessagesLimit: replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish),
MessagesExpiryDuration: conf.CacheDuration,
EmailsLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish),
ReservationsLimit: visitorDefaultReservationsLimit,
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
AttachmentExpiryDuration: conf.AttachmentExpiryDuration,
} }
return fmt.Sprintf("ip:%s", ip.String())
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,11 @@ package user
import ( import (
"database/sql" "database/sql"
"fmt"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/util"
"net/netip"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -12,9 +16,9 @@ import (
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
func TestManager_FullScenario_Default_DenyAll(t *testing.T) { func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test")) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
@@ -92,20 +96,44 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
func TestManager_AddUser_Invalid(t *testing.T) { func TestManager_AddUser_Invalid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, "unit-test")) require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin))
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", "unit-test")) require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
} }
func TestManager_AddUser_Timing(t *testing.T) { func TestManager_AddUser_Timing(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
start := time.Now().UnixMilli() start := time.Now().UnixMilli()
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, "unit-test")) require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
} }
func TestManager_AddUser_And_Query(t *testing.T) {
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
require.Nil(t, a.ChangeBilling("user", &Billing{
StripeCustomerID: "acct_123",
StripeSubscriptionID: "sub_123",
StripeSubscriptionStatus: "active",
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
StripeSubscriptionCancelAt: time.Unix(0, 0),
}))
u, err := a.User("user")
require.Nil(t, err)
require.Equal(t, "user", u.Name)
u2, err := a.UserByID(u.ID)
require.Nil(t, err)
require.Equal(t, u.Name, u2.Name)
u3, err := a.UserByStripeCustomer("acct_123")
require.Nil(t, err)
require.Equal(t, u.ID, u3.ID)
}
func TestManager_Authenticate_Timing(t *testing.T) { func TestManager_Authenticate_Timing(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, "unit-test")) require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
// Timing a correct attempt // Timing a correct attempt
start := time.Now().UnixMilli() start := time.Now().UnixMilli()
@@ -126,10 +154,60 @@ func TestManager_Authenticate_Timing(t *testing.T) {
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis) require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
} }
func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
// Create user, add reservations and token
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead))
u, err := a.User("user")
require.Nil(t, err)
require.False(t, u.Deleted)
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
require.Nil(t, err)
u, err = a.Authenticate("user", "pass")
require.Nil(t, err)
_, err = a.AuthenticateToken(token.Value)
require.Nil(t, err)
reservations, err := a.Reservations("user")
require.Nil(t, err)
require.Equal(t, 1, len(reservations))
// Mark deleted: cannot auth anymore, and all reservations are gone
require.Nil(t, a.MarkUserRemoved(u))
_, err = a.Authenticate("user", "pass")
require.Equal(t, ErrUnauthenticated, err)
_, err = a.AuthenticateToken(token.Value)
require.Equal(t, ErrUnauthenticated, err)
reservations, err = a.Reservations("user")
require.Nil(t, err)
require.Equal(t, 0, len(reservations))
// Make sure user is still there
u, err = a.User("user")
require.Nil(t, err)
require.True(t, u.Deleted)
_, err = a.db.Exec("UPDATE user SET deleted = ? WHERE id = ?", time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID)
require.Nil(t, err)
require.Nil(t, a.RemoveDeletedUsers())
_, err = a.User("user")
require.Equal(t, ErrUserNotFound, err)
}
func TestManager_UserManagement(t *testing.T) { func TestManager_UserManagement(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test")) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite)) require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
@@ -141,7 +219,7 @@ func TestManager_UserManagement(t *testing.T) {
phil, err := a.User("phil") phil, err := a.User("phil")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "phil", phil.Name) require.Equal(t, "phil", phil.Name)
require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$")) require.True(t, strings.HasPrefix(phil.Hash, "$2a$04$")) // Min cost for testing
require.Equal(t, RoleAdmin, phil.Role) require.Equal(t, RoleAdmin, phil.Role)
philGrants, err := a.Grants("phil") philGrants, err := a.Grants("phil")
@@ -151,7 +229,7 @@ func TestManager_UserManagement(t *testing.T) {
ben, err := a.User("ben") ben, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "ben", ben.Name) require.Equal(t, "ben", ben.Name)
require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) require.True(t, strings.HasPrefix(ben.Hash, "$2a$04$")) // Min cost for testing
require.Equal(t, RoleUser, ben.Role) require.Equal(t, RoleUser, ben.Role)
benGrants, err := a.Grants("ben") benGrants, err := a.Grants("ben")
@@ -219,7 +297,7 @@ func TestManager_UserManagement(t *testing.T) {
func TestManager_ChangePassword(t *testing.T) { func TestManager_ChangePassword(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test")) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
_, err := a.Authenticate("phil", "phil") _, err := a.Authenticate("phil", "phil")
require.Nil(t, err) require.Nil(t, err)
@@ -233,7 +311,7 @@ func TestManager_ChangePassword(t *testing.T) {
func TestManager_ChangeRole(t *testing.T) { func TestManager_ChangeRole(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead)) require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
@@ -258,9 +336,10 @@ func TestManager_ChangeRole(t *testing.T) {
func TestManager_Reservations(t *testing.T) { func TestManager_Reservations(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.ReserveAccess("ben", "ztopic", PermissionDenyAll)) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.ReserveAccess("ben", "readme", PermissionRead)) require.Nil(t, a.AddReservation("ben", "ztopic", PermissionDenyAll))
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead))
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead)) require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
reservations, err := a.Reservations("ben") reservations, err := a.Reservations("ben")
@@ -276,35 +355,67 @@ func TestManager_Reservations(t *testing.T) {
Owner: PermissionReadWrite, Owner: PermissionReadWrite,
Everyone: PermissionDenyAll, Everyone: PermissionDenyAll,
}, reservations[1]) }, reservations[1])
b, err := a.HasReservation("ben", "readme")
require.Nil(t, err)
require.True(t, b)
b, err = a.HasReservation("notben", "readme")
require.Nil(t, err)
require.False(t, b)
b, err = a.HasReservation("ben", "something-else")
require.Nil(t, err)
require.False(t, b)
count, err := a.ReservationsCount("ben")
require.Nil(t, err)
require.Equal(t, int64(2), count)
count, err = a.ReservationsCount("phil")
require.Nil(t, err)
require.Equal(t, int64(0), count)
err = a.AllowReservation("phil", "readme")
require.Equal(t, errTopicOwnedByOthers, err)
err = a.AllowReservation("phil", "not-reserved")
require.Nil(t, err)
// Now remove them again
require.Nil(t, a.RemoveReservations("ben", "ztopic", "readme"))
count, err = a.ReservationsCount("ben")
require.Nil(t, err)
require.Equal(t, int64(0), count)
} }
func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) { func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.CreateTier(&Tier{ require.Nil(t, a.AddTier(&Tier{
Code: "pro", Code: "pro",
Name: "ntfy Pro", Name: "ntfy Pro",
StripePriceID: "price123", StripePriceID: "price123",
MessagesLimit: 5_000, MessageLimit: 5_000,
MessagesExpiryDuration: 3 * 24 * time.Hour, MessageExpiryDuration: 3 * 24 * time.Hour,
EmailsLimit: 50, EmailLimit: 50,
ReservationsLimit: 5, ReservationLimit: 5,
AttachmentFileSizeLimit: 52428800, AttachmentFileSizeLimit: 52428800,
AttachmentTotalSizeLimit: 524288000, AttachmentTotalSizeLimit: 524288000,
AttachmentExpiryDuration: 24 * time.Hour, AttachmentExpiryDuration: 24 * time.Hour,
})) }))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
require.Nil(t, a.ChangeTier("ben", "pro")) require.Nil(t, a.ChangeTier("ben", "pro"))
require.Nil(t, a.ReserveAccess("ben", "mytopic", PermissionDenyAll)) require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll))
ben, err := a.User("ben") ben, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, RoleUser, ben.Role) require.Equal(t, RoleUser, ben.Role)
require.Equal(t, "pro", ben.Tier.Code) require.Equal(t, "pro", ben.Tier.Code)
require.Equal(t, true, ben.Tier.Paid) require.Equal(t, int64(5000), ben.Tier.MessageLimit)
require.Equal(t, int64(5000), ben.Tier.MessagesLimit) require.Equal(t, 3*24*time.Hour, ben.Tier.MessageExpiryDuration)
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration) require.Equal(t, int64(50), ben.Tier.EmailLimit)
require.Equal(t, int64(50), ben.Tier.EmailsLimit) require.Equal(t, int64(5), ben.Tier.ReservationLimit)
require.Equal(t, int64(5), ben.Tier.ReservationsLimit)
require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit) require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)
require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit) require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)
require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration) require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)
@@ -340,15 +451,16 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
func TestManager_Token_Valid(t *testing.T) { func TestManager_Token_Valid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
u, err := a.User("ben") u, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
// Create token for user // Create token for user
token, err := a.CreateToken(u) token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
require.Equal(t, "some label", token.Label)
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix()) require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix())
u2, err := a.AuthenticateToken(token.Value) u2, err := a.AuthenticateToken(token.Value)
@@ -356,16 +468,34 @@ func TestManager_Token_Valid(t *testing.T) {
require.Equal(t, u.Name, u2.Name) require.Equal(t, u.Name, u2.Name)
require.Equal(t, token.Value, u2.Token) require.Equal(t, token.Value, u2.Token)
token2, err := a.Token(u.ID, token.Value)
require.Nil(t, err)
require.Equal(t, token.Value, token2.Value)
require.Equal(t, "some label", token2.Label)
tokens, err := a.Tokens(u.ID)
require.Nil(t, err)
require.Equal(t, 1, len(tokens))
require.Equal(t, "some label", tokens[0].Label)
tokens, err = a.Tokens("u_notauser")
require.Nil(t, err)
require.Equal(t, 0, len(tokens))
// Remove token and auth again // Remove token and auth again
require.Nil(t, a.RemoveToken(u2)) require.Nil(t, a.RemoveToken(u2.ID, u2.Token))
u3, err := a.AuthenticateToken(token.Value) u3, err := a.AuthenticateToken(token.Value)
require.Equal(t, ErrUnauthenticated, err) require.Equal(t, ErrUnauthenticated, err)
require.Nil(t, u3) require.Nil(t, u3)
tokens, err = a.Tokens(u.ID)
require.Nil(t, err)
require.Equal(t, 0, len(tokens))
} }
func TestManager_Token_Invalid(t *testing.T) { func TestManager_Token_Invalid(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length
require.Nil(t, u) require.Nil(t, u)
@@ -376,20 +506,26 @@ func TestManager_Token_Invalid(t *testing.T) {
require.Equal(t, ErrUnauthenticated, err) require.Equal(t, ErrUnauthenticated, err)
} }
func TestManager_Token_NotFound(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
_, err := a.Token("u_bla", "notfound")
require.Equal(t, ErrTokenNotFound, err)
}
func TestManager_Token_Expire(t *testing.T) { func TestManager_Token_Expire(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
u, err := a.User("ben") u, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
// Create tokens for user // Create tokens for user
token1, err := a.CreateToken(u) token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token1.Value) require.NotEmpty(t, token1.Value)
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix()) require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
token2, err := a.CreateToken(u) token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token2.Value) require.NotEmpty(t, token2.Value)
require.NotEqual(t, token1.Value, token2.Value) require.NotEqual(t, token1.Value, token2.Value)
@@ -426,34 +562,34 @@ func TestManager_Token_Expire(t *testing.T) {
func TestManager_Token_Extend(t *testing.T) { func TestManager_Token_Extend(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
// Try to extend token for user without token // Try to extend token for user without token
u, err := a.User("ben") u, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
_, err = a.ExtendToken(u) _, err = a.ChangeToken(u.ID, u.Token, util.String("some label"), util.Time(time.Now().Add(time.Hour)))
require.Equal(t, errNoTokenProvided, err) require.Equal(t, errNoTokenProvided, err)
// Create token for user // Create token for user
token, err := a.CreateToken(u) token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
userWithToken, err := a.AuthenticateToken(token.Value) userWithToken, err := a.AuthenticateToken(token.Value)
require.Nil(t, err) require.Nil(t, err)
time.Sleep(1100 * time.Millisecond) extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour)))
extendedToken, err := a.ExtendToken(userWithToken)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, token.Value, extendedToken.Value) require.Equal(t, token.Value, extendedToken.Value)
require.Equal(t, "changed label", extendedToken.Label)
require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix()) require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix())
require.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix())
} }
func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
a := newTestManager(t, PermissionDenyAll) a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
// Try to extend token for user without token // Try to extend token for user without token
u, err := a.User("ben") u, err := a.User("ben")
@@ -462,8 +598,8 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
// Tokens // Tokens
baseTime := time.Now().Add(24 * time.Hour) baseTime := time.Now().Add(24 * time.Hour)
tokens := make([]string, 0) tokens := make([]string, 0)
for i := 0; i < 12; i++ { for i := 0; i < 22; i++ {
token, err := a.CreateToken(u) token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
require.Nil(t, err) require.Nil(t, err)
require.NotEmpty(t, token.Value) require.NotEmpty(t, token.Value)
tokens = append(tokens, token.Value) tokens = append(tokens, token.Value)
@@ -479,7 +615,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
_, err = a.AuthenticateToken(tokens[1]) _, err = a.AuthenticateToken(tokens[1])
require.Equal(t, ErrUnauthenticated, err) require.Equal(t, ErrUnauthenticated, err)
for i := 2; i < 12; i++ { for i := 2; i < 22; i++ {
userWithToken, err := a.AuthenticateToken(tokens[i]) userWithToken, err := a.AuthenticateToken(tokens[i])
require.Nil(t, err, "token[%d]=%s failed", i, tokens[i]) require.Nil(t, err, "token[%d]=%s failed", i, tokens[i])
require.Equal(t, "ben", userWithToken.Name) require.Equal(t, "ben", userWithToken.Name)
@@ -491,23 +627,23 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.True(t, rows.Next()) require.True(t, rows.Next())
require.Nil(t, rows.Scan(&count)) require.Nil(t, rows.Scan(&count))
require.Equal(t, 10, count) require.Equal(t, 20, count)
} }
func TestManager_EnqueueStats(t *testing.T) { func TestManager_EnqueueStats_ResetStats(t *testing.T) {
a, err := newManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, 1500*time.Millisecond) a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
// Baseline: No messages or emails // Baseline: No messages or emails
u, err := a.User("ben") u, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), u.Stats.Messages) require.Equal(t, int64(0), u.Stats.Messages)
require.Equal(t, int64(0), u.Stats.Emails) require.Equal(t, int64(0), u.Stats.Emails)
a.EnqueueUserStats(u.ID, &Stats{
u.Stats.Messages = 11 Messages: 11,
u.Stats.Emails = 2 Emails: 2,
a.EnqueueStats(u) })
// Still no change, because it's queued asynchronously // Still no change, because it's queued asynchronously
u, err = a.User("ben") u, err = a.User("ben")
@@ -522,49 +658,260 @@ func TestManager_EnqueueStats(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(11), u.Stats.Messages) require.Equal(t, int64(11), u.Stats.Messages)
require.Equal(t, int64(2), u.Stats.Emails) require.Equal(t, int64(2), u.Stats.Emails)
// Now reset stats (enqueued stats will be thrown out)
a.EnqueueUserStats(u.ID, &Stats{
Messages: 99,
Emails: 23,
})
require.Nil(t, a.ResetStats())
u, err = a.User("ben")
require.Nil(t, err)
require.Equal(t, int64(0), u.Stats.Messages)
require.Equal(t, int64(0), u.Stats.Emails)
}
func TestManager_EnqueueTokenUpdate(t *testing.T) {
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond)
require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
// Create user and token
u, err := a.User("ben")
require.Nil(t, err)
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
require.Nil(t, err)
// Queue token update
a.EnqueueTokenUpdate(token.Value, &TokenUpdate{
LastAccess: time.Unix(111, 0).UTC(),
LastOrigin: netip.MustParseAddr("1.2.3.3"),
})
// Token has not changed yet.
token2, err := a.Token(u.ID, token.Value)
require.Nil(t, err)
require.Equal(t, token.LastAccess.Unix(), token2.LastAccess.Unix())
require.Equal(t, token.LastOrigin, token2.LastOrigin)
// After a second or so they should be persisted
time.Sleep(time.Second)
token3, err := a.Token(u.ID, token.Value)
require.Nil(t, err)
require.Equal(t, time.Unix(111, 0).UTC().Unix(), token3.LastAccess.Unix())
require.Equal(t, netip.MustParseAddr("1.2.3.3"), token3.LastOrigin)
} }
func TestManager_ChangeSettings(t *testing.T) { func TestManager_ChangeSettings(t *testing.T) {
a, err := newManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, 1500*time.Millisecond) a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test")) require.Nil(t, a.AddUser("ben", "ben", RoleUser))
// No settings // No settings
u, err := a.User("ben") u, err := a.User("ben")
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, u.Prefs.Subscriptions) require.Nil(t, u.Prefs.Subscriptions)
require.Nil(t, u.Prefs.Notification) require.Nil(t, u.Prefs.Notification)
require.Equal(t, "", u.Prefs.Language) require.Nil(t, u.Prefs.Language)
// Save with new settings // Save with new settings
u.Prefs = &Prefs{ prefs := &Prefs{
Language: "de", Language: util.String("de"),
Notification: &NotificationPrefs{ Notification: &NotificationPrefs{
Sound: "ding", Sound: util.String("ding"),
MinPriority: 2, MinPriority: util.Int(2),
}, },
Subscriptions: []*Subscription{ Subscriptions: []*Subscription{
{ {
ID: "someID",
BaseURL: "https://ntfy.sh", BaseURL: "https://ntfy.sh",
Topic: "mytopic", Topic: "mytopic",
DisplayName: "My Topic", DisplayName: util.String("My Topic"),
}, },
}, },
} }
require.Nil(t, a.ChangeSettings(u)) require.Nil(t, a.ChangeSettings(u.ID, prefs))
// Read again // Read again
u, err = a.User("ben") u, err = a.User("ben")
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "de", u.Prefs.Language) require.Equal(t, util.String("de"), u.Prefs.Language)
require.Equal(t, "ding", u.Prefs.Notification.Sound) require.Equal(t, util.String("ding"), u.Prefs.Notification.Sound)
require.Equal(t, 2, u.Prefs.Notification.MinPriority) require.Equal(t, util.Int(2), u.Prefs.Notification.MinPriority)
require.Equal(t, 0, u.Prefs.Notification.DeleteAfter) require.Nil(t, u.Prefs.Notification.DeleteAfter)
require.Equal(t, "someID", u.Prefs.Subscriptions[0].ID)
require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL) require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL)
require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic) require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic)
require.Equal(t, "My Topic", u.Prefs.Subscriptions[0].DisplayName) require.Equal(t, util.String("My Topic"), u.Prefs.Subscriptions[0].DisplayName)
}
func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
// Create tier and user
require.Nil(t, a.AddTier(&Tier{
Code: "supporter",
Name: "Supporter",
MessageLimit: 1,
MessageExpiryDuration: time.Second,
EmailLimit: 1,
ReservationLimit: 1,
AttachmentFileSizeLimit: 1,
AttachmentTotalSizeLimit: 1,
AttachmentExpiryDuration: time.Second,
AttachmentBandwidthLimit: 1,
StripePriceID: "price_1",
}))
require.Nil(t, a.AddTier(&Tier{
Code: "pro",
Name: "Pro",
MessageLimit: 123,
MessageExpiryDuration: 86400 * time.Second,
EmailLimit: 32,
ReservationLimit: 2,
AttachmentFileSizeLimit: 1231231,
AttachmentTotalSizeLimit: 123123,
AttachmentExpiryDuration: 10800 * time.Second,
AttachmentBandwidthLimit: 21474836480,
StripePriceID: "price_2",
}))
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.ChangeTier("phil", "pro"))
ti, err := a.Tier("pro")
require.Nil(t, err)
u, err := a.User("phil")
require.Nil(t, err)
// These are populated by different SQL queries
require.Equal(t, ti, u.Tier)
// Fields
require.True(t, strings.HasPrefix(ti.ID, "ti_"))
require.Equal(t, "pro", ti.Code)
require.Equal(t, "Pro", ti.Name)
require.Equal(t, int64(123), ti.MessageLimit)
require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration)
require.Equal(t, int64(32), ti.EmailLimit)
require.Equal(t, int64(2), ti.ReservationLimit)
require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit)
require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)
require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_2", ti.StripePriceID)
// Update tier
ti.EmailLimit = 999999
require.Nil(t, a.UpdateTier(ti))
// List tiers
tiers, err := a.Tiers()
require.Nil(t, err)
require.Equal(t, 2, len(tiers))
ti = tiers[0]
require.Equal(t, "supporter", ti.Code)
require.Equal(t, "Supporter", ti.Name)
require.Equal(t, int64(1), ti.MessageLimit)
require.Equal(t, time.Second, ti.MessageExpiryDuration)
require.Equal(t, int64(1), ti.EmailLimit)
require.Equal(t, int64(1), ti.ReservationLimit)
require.Equal(t, int64(1), ti.AttachmentFileSizeLimit)
require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)
require.Equal(t, time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(1), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_1", ti.StripePriceID)
ti = tiers[1]
require.Equal(t, "pro", ti.Code)
require.Equal(t, "Pro", ti.Name)
require.Equal(t, int64(123), ti.MessageLimit)
require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration)
require.Equal(t, int64(999999), ti.EmailLimit) // Updatedd!
require.Equal(t, int64(2), ti.ReservationLimit)
require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit)
require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)
require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_2", ti.StripePriceID)
ti, err = a.TierByStripePrice("price_1")
require.Nil(t, err)
require.Equal(t, "supporter", ti.Code)
require.Equal(t, "Supporter", ti.Name)
require.Equal(t, int64(1), ti.MessageLimit)
require.Equal(t, time.Second, ti.MessageExpiryDuration)
require.Equal(t, int64(1), ti.EmailLimit)
require.Equal(t, int64(1), ti.ReservationLimit)
require.Equal(t, int64(1), ti.AttachmentFileSizeLimit)
require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)
require.Equal(t, time.Second, ti.AttachmentExpiryDuration)
require.Equal(t, int64(1), ti.AttachmentBandwidthLimit)
require.Equal(t, "price_1", ti.StripePriceID)
// Cannot remove tier, since user has this tier
require.Error(t, a.RemoveTier("pro"))
// CAN remove this tier
require.Nil(t, a.RemoveTier("supporter"))
tiers, err = a.Tiers()
require.Nil(t, err)
require.Equal(t, 1, len(tiers))
require.Equal(t, "pro", tiers[0].Code)
require.Equal(t, "pro", tiers[0].Code)
}
func TestAccount_Tier_Create_With_ID(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddTier(&Tier{
ID: "ti_123",
Code: "pro",
}))
ti, err := a.Tier("pro")
require.Nil(t, err)
require.Equal(t, "ti_123", ti.ID)
}
func TestManager_Tier_Change_And_Reset(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
// Create tier and user
require.Nil(t, a.AddTier(&Tier{
Code: "supporter",
Name: "Supporter",
ReservationLimit: 3,
}))
require.Nil(t, a.AddTier(&Tier{
Code: "pro",
Name: "Pro",
ReservationLimit: 4,
}))
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
require.Nil(t, a.ChangeTier("phil", "pro"))
// Add 10 reservations (pro tier allows that)
for i := 0; i < 4; i++ {
require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite))
}
// Downgrading will not work (too many reservations)
require.Equal(t, ErrTooManyReservations, a.ChangeTier("phil", "supporter"))
// Downgrade after removing a reservation
require.Nil(t, a.RemoveReservations("phil", "topic0"))
require.Nil(t, a.ChangeTier("phil", "supporter"))
// Resetting will not work (too many reservations)
require.Equal(t, ErrTooManyReservations, a.ResetTier("phil"))
// Resetting after removing all reservations
require.Nil(t, a.RemoveReservations("phil", "topic1", "topic2", "topic3"))
require.Nil(t, a.ResetTier("phil"))
} }
func TestSqliteCache_Migration_From1(t *testing.T) { func TestSqliteCache_Migration_From1(t *testing.T) {
@@ -609,7 +956,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
// Create manager to trigger migration // Create manager to trigger migration
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, userStatsQueueWriterInterval) a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
checkSchemaVersion(t, a.db) checkSchemaVersion(t, a.db)
users, err := a.Users() users, err := a.Users()
@@ -626,11 +973,14 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
everyoneGrants, err := a.Grants(Everyone) everyoneGrants, err := a.Grants(Everyone)
require.Nil(t, err) require.Nil(t, err)
require.True(t, strings.HasPrefix(phil.ID, "u_"))
require.Equal(t, "phil", phil.Name) require.Equal(t, "phil", phil.Name)
require.Equal(t, RoleAdmin, phil.Role) require.Equal(t, RoleAdmin, phil.Role)
require.Equal(t, syncTopicLength, len(phil.SyncTopic)) require.Equal(t, syncTopicLength, len(phil.SyncTopic))
require.Equal(t, 0, len(philGrants)) require.Equal(t, 0, len(philGrants))
require.True(t, strings.HasPrefix(ben.ID, "u_"))
require.NotEqual(t, phil.ID, ben.ID)
require.Equal(t, "ben", ben.Name) require.Equal(t, "ben", ben.Name)
require.Equal(t, RoleUser, ben.Role) require.Equal(t, RoleUser, ben.Role)
require.Equal(t, syncTopicLength, len(ben.SyncTopic)) require.Equal(t, syncTopicLength, len(ben.SyncTopic))
@@ -641,6 +991,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
require.Equal(t, "secret", benGrants[1].TopicPattern) require.Equal(t, "secret", benGrants[1].TopicPattern)
require.Equal(t, PermissionRead, benGrants[1].Allow) require.Equal(t, PermissionRead, benGrants[1].Allow)
require.Equal(t, "u_everyone", everyone.ID)
require.Equal(t, Everyone, everyone.Name) require.Equal(t, Everyone, everyone.Name)
require.Equal(t, RoleAnonymous, everyone.Role) require.Equal(t, RoleAnonymous, everyone.Role)
require.Equal(t, 1, len(everyoneGrants)) require.Equal(t, 1, len(everyoneGrants))
@@ -660,11 +1011,11 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) {
} }
func newTestManager(t *testing.T, defaultAccess Permission) *Manager { func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
return newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", defaultAccess, userStatsQueueWriterInterval) return newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", defaultAccess, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
} }
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, statsWriterInterval time.Duration) *Manager { func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
a, err := newManager(filename, startupQueries, defaultAccess, statsWriterInterval) a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval)
require.Nil(t, err) require.Nil(t, err)
return a return a
} }

View File

@@ -1,15 +1,18 @@
// Package user deals with authentication and authorization against topics
package user package user
import ( import (
"errors" "errors"
"github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74"
"heckel.io/ntfy/log"
"net/netip"
"regexp" "regexp"
"strings"
"time" "time"
) )
// User is a struct that represents a user // User is a struct that represents a user
type User struct { type User struct {
ID string
Name string Name string
Hash string // password hash (bcrypt) Hash string // password hash (bcrypt)
Token string // Only set if token was used to log in Token string // Only set if token was used to log in
@@ -19,8 +22,26 @@ type User struct {
Stats *Stats Stats *Stats
Billing *Billing Billing *Billing
SyncTopic string SyncTopic string
Created time.Time Deleted bool
LastSeen time.Time }
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
// or if the user itself is nil.
func (u *User) TierID() string {
if u == nil || u.Tier == nil {
return ""
}
return u.Tier.ID
}
// IsAdmin returns true if the user is an admin
func (u *User) IsAdmin() bool {
return u != nil && u.Role == RoleAdmin
}
// IsUser returns true if the user is a regular user, not an admin
func (u *User) IsUser() bool {
return u != nil && u.Role == RoleUser
} }
// Auther is an interface for authentication and authorization // Auther is an interface for authentication and authorization
@@ -37,45 +58,71 @@ type Auther interface {
// Token represents a user token, including expiry date // Token represents a user token, including expiry date
type Token struct { type Token struct {
Value string Value string
Expires time.Time Label string
LastAccess time.Time
LastOrigin netip.Addr
Expires time.Time
}
// TokenUpdate holds information about the last access time and origin IP address of a token
type TokenUpdate struct {
LastAccess time.Time
LastOrigin netip.Addr
} }
// Prefs represents a user's configuration settings // Prefs represents a user's configuration settings
type Prefs struct { type Prefs struct {
Language string `json:"language,omitempty"` Language *string `json:"language,omitempty"`
Notification *NotificationPrefs `json:"notification,omitempty"` Notification *NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*Subscription `json:"subscriptions,omitempty"` Subscriptions []*Subscription `json:"subscriptions,omitempty"`
} }
// Tier represents a user's account type, including its account limits // Tier represents a user's account type, including its account limits
type Tier struct { type Tier struct {
Code string ID string // Tier identifier (ti_...)
Name string Code string // Code of the tier
Paid bool Name string // Name of the tier
MessagesLimit int64 MessageLimit int64 // Daily message limit
MessagesExpiryDuration time.Duration MessageExpiryDuration time.Duration // Cache duration for messages
EmailsLimit int64 EmailLimit int64 // Daily email limit
ReservationsLimit int64 ReservationLimit int64 // Number of topic reservations allowed by user
AttachmentFileSizeLimit int64 AttachmentFileSizeLimit int64 // Max file size per file (bytes)
AttachmentTotalSizeLimit int64 AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
AttachmentExpiryDuration time.Duration AttachmentExpiryDuration time.Duration // Duration after which attachments will be deleted
StripePriceID string AttachmentBandwidthLimit int64 // Daily bandwidth limit for the user
StripePriceID string // Price ID for paid tiers (price_...)
}
// Context returns fields for the log
func (t *Tier) Context() log.Context {
return log.Context{
"tier_id": t.ID,
"tier_code": t.Code,
"stripe_price_id": t.StripePriceID,
}
} }
// Subscription represents a user's topic subscription // Subscription represents a user's topic subscription
type Subscription struct { type Subscription struct {
ID string `json:"id"` BaseURL string `json:"base_url"`
BaseURL string `json:"base_url"` Topic string `json:"topic"`
Topic string `json:"topic"` DisplayName *string `json:"display_name"`
DisplayName string `json:"display_name"` }
// Context returns fields for the log
func (s *Subscription) Context() log.Context {
return log.Context{
"base_url": s.BaseURL,
"topic": s.Topic,
}
} }
// NotificationPrefs represents the user's notification settings // NotificationPrefs represents the user's notification settings
type NotificationPrefs struct { type NotificationPrefs struct {
Sound string `json:"sound,omitempty"` Sound *string `json:"sound,omitempty"`
MinPriority int `json:"min_priority,omitempty"` MinPriority *int `json:"min_priority,omitempty"`
DeleteAfter int `json:"delete_after,omitempty"` DeleteAfter *int `json:"delete_after,omitempty"`
} }
// Stats is a struct holding daily user statistics // Stats is a struct holding daily user statistics
@@ -131,7 +178,7 @@ func NewPermission(read, write bool) Permission {
// ParsePermission parses the string representation and returns a Permission // ParsePermission parses the string representation and returns a Permission
func ParsePermission(s string) (Permission, error) { func ParsePermission(s string) (Permission, error) {
switch s { switch strings.ToLower(s) {
case "read-write", "rw": case "read-write", "rw":
return NewPermission(true, true), nil return NewPermission(true, true), nil
case "read-only", "read", "ro": case "read-only", "read", "ro":
@@ -184,7 +231,8 @@ const (
// Everyone is a special username representing anonymous users // Everyone is a special username representing anonymous users
const ( const (
Everyone = "*" Everyone = "*"
everyoneID = "u_everyone"
) )
var ( var (
@@ -226,5 +274,6 @@ var (
ErrInvalidArgument = errors.New("invalid argument") ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrTierNotFound = errors.New("tier not found") ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit") ErrTooManyReservations = errors.New("new tier has lower reservation limit")
) )

60
user/types_test.go Normal file
View File

@@ -0,0 +1,60 @@
package user
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestPermission(t *testing.T) {
require.Equal(t, PermissionReadWrite, NewPermission(true, true))
require.Equal(t, PermissionRead, NewPermission(true, false))
require.Equal(t, PermissionWrite, NewPermission(false, true))
require.Equal(t, PermissionDenyAll, NewPermission(false, false))
require.True(t, PermissionReadWrite.IsReadWrite())
require.True(t, PermissionReadWrite.IsRead())
require.True(t, PermissionReadWrite.IsWrite())
require.True(t, PermissionRead.IsRead())
require.True(t, PermissionWrite.IsWrite())
}
func TestParsePermission(t *testing.T) {
_, err := ParsePermission("no")
require.NotNil(t, err)
p, err := ParsePermission("read-write")
require.Nil(t, err)
require.Equal(t, PermissionReadWrite, p)
p, err = ParsePermission("rw")
require.Nil(t, err)
require.Equal(t, PermissionReadWrite, p)
p, err = ParsePermission("read-only")
require.Nil(t, err)
require.Equal(t, PermissionRead, p)
p, err = ParsePermission("WRITE")
require.Nil(t, err)
require.Equal(t, PermissionWrite, p)
p, err = ParsePermission("deny-all")
require.Nil(t, err)
require.Equal(t, PermissionDenyAll, p)
}
func TestAllowedTier(t *testing.T) {
require.False(t, AllowedTier(" no"))
require.True(t, AllowedTier("yes"))
}
func TestTierContext(t *testing.T) {
tier := &Tier{
ID: "ti_abc",
Code: "pro",
StripePriceID: "price_123",
}
context := tier.Context()
require.Equal(t, "ti_abc", context["tier_id"])
require.Equal(t, "pro", context["tier_code"])
require.Equal(t, "price_123", context["stripe_price_id"])
}

View File

@@ -13,8 +13,17 @@ var ErrLimitReached = errors.New("limit reached")
// Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value // Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value
type Limiter interface { type Limiter interface {
// Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached // Allow adds one to the limiters value, or returns false if the limit has been reached
Allow(n int64) error Allow() bool
// AllowN adds n to the limiters value, or returns false if the limit has been reached
AllowN(n int64) bool
// Value returns the current internal limiter value
Value() int64
// Reset resets the state of the limiter
Reset()
} }
// FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached // FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached
@@ -25,33 +34,78 @@ type FixedLimiter struct {
mu sync.Mutex mu sync.Mutex
} }
var _ Limiter = (*FixedLimiter)(nil)
// NewFixedLimiter creates a new Limiter // NewFixedLimiter creates a new Limiter
func NewFixedLimiter(limit int64) *FixedLimiter { func NewFixedLimiter(limit int64) *FixedLimiter {
return NewFixedLimiterWithValue(limit, 0)
}
// NewFixedLimiterWithValue creates a new Limiter and sets the initial value
func NewFixedLimiterWithValue(limit, value int64) *FixedLimiter {
return &FixedLimiter{ return &FixedLimiter{
limit: limit, limit: limit,
value: value,
} }
} }
// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was // Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was
// exceeded after adding n, ErrLimitReached is returned. // exceeded, false is returned.
func (l *FixedLimiter) Allow(n int64) error { func (l *FixedLimiter) Allow() bool {
return l.AllowN(1)
}
// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
// exceeded after adding n, false is returned.
func (l *FixedLimiter) AllowN(n int64) bool {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
if l.value+n > l.limit { if l.value+n > l.limit {
return ErrLimitReached return false
} }
l.value += n l.value += n
return nil return true
}
// Value returns the current limiter value
func (l *FixedLimiter) Value() int64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.value
}
// Reset sets the limiter's value back to zero
func (l *FixedLimiter) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.value = 0
} }
// RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit. // RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit.
type RateLimiter struct { type RateLimiter struct {
r rate.Limit
b int
value int64
limiter *rate.Limiter limiter *rate.Limiter
mu sync.Mutex
} }
var _ Limiter = (*RateLimiter)(nil)
// NewRateLimiter creates a new RateLimiter // NewRateLimiter creates a new RateLimiter
func NewRateLimiter(r rate.Limit, b int) *RateLimiter { func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
return NewRateLimiterWithValue(r, b, 0)
}
// NewRateLimiterWithValue creates a new RateLimiter with the given starting value.
//
// Note that the starting value only has informational value. It does not impact the underlying
// value of the rate.Limiter.
func NewRateLimiterWithValue(r rate.Limit, b int, value int64) *RateLimiter {
return &RateLimiter{ return &RateLimiter{
r: r,
b: b,
value: value,
limiter: rate.NewLimiter(r, b), limiter: rate.NewLimiter(r, b),
} }
} }
@@ -62,16 +116,40 @@ func NewBytesLimiter(bytes int, interval time.Duration) *RateLimiter {
return NewRateLimiter(rate.Limit(bytes)*rate.Every(interval), bytes) return NewRateLimiter(rate.Limit(bytes)*rate.Every(interval), bytes)
} }
// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was // Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was
// exceeded after adding n, ErrLimitReached is returned. // exceeded, false is returned.
func (l *RateLimiter) Allow(n int64) error { func (l *RateLimiter) Allow() bool {
return l.AllowN(1)
}
// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
// exceeded after adding n, false is returned.
func (l *RateLimiter) AllowN(n int64) bool {
if n <= 0 { if n <= 0 {
return nil // No-op. Can't take back bytes you're written! return false // No-op. Can't take back bytes you're written!
} }
l.mu.Lock()
defer l.mu.Unlock()
if !l.limiter.AllowN(time.Now(), int(n)) { if !l.limiter.AllowN(time.Now(), int(n)) {
return ErrLimitReached return false
} }
return nil l.value += n
return true
}
// Value returns the current limiter value
func (l *RateLimiter) Value() int64 {
l.mu.Lock()
defer l.mu.Unlock()
return l.value
}
// Reset sets the limiter's value back to zero, and resets the underlying rate.Limiter
func (l *RateLimiter) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.limiter = rate.NewLimiter(l.r, l.b)
l.value = 0
} }
// LimitWriter implements an io.Writer that will pass through all Write calls to the underlying // LimitWriter implements an io.Writer that will pass through all Write calls to the underlying
@@ -97,9 +175,9 @@ func (w *LimitWriter) Write(p []byte) (n int, err error) {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
for i := 0; i < len(w.limiters); i++ { for i := 0; i < len(w.limiters); i++ {
if err := w.limiters[i].Allow(int64(len(p))); err != nil { if !w.limiters[i].AllowN(int64(len(p))) {
for j := i - 1; j >= 0; j-- { for j := i - 1; j >= 0; j-- {
w.limiters[j].Allow(-int64(len(p))) // Revert limiters limits if allowed w.limiters[j].AllowN(-int64(len(p))) // Revert limiters limits if not allowed
} }
return 0, ErrLimitReached return 0, ErrLimitReached
} }

View File

@@ -7,26 +7,31 @@ import (
"time" "time"
) )
func TestFixedLimiter_Add(t *testing.T) { func TestFixedLimiter_AllowValueReset(t *testing.T) {
l := NewFixedLimiter(10) l := NewFixedLimiter(10)
if err := l.Allow(5); err != nil { require.True(t, l.AllowN(5))
t.Fatal(err) require.Equal(t, int64(5), l.Value())
}
if err := l.Allow(5); err != nil { require.True(t, l.AllowN(5))
t.Fatal(err) require.Equal(t, int64(10), l.Value())
}
if err := l.Allow(5); err != ErrLimitReached { require.False(t, l.Allow())
t.Fatalf("expected ErrLimitReached, got %#v", err) require.Equal(t, int64(10), l.Value())
}
l.Reset()
require.Equal(t, int64(0), l.Value())
require.True(t, l.Allow())
require.True(t, l.AllowN(9))
require.False(t, l.Allow())
} }
func TestFixedLimiter_AddSub(t *testing.T) { func TestFixedLimiter_AddSub(t *testing.T) {
l := NewFixedLimiter(10) l := NewFixedLimiter(10)
l.Allow(5) l.AllowN(5)
if l.value != 5 { if l.value != 5 {
t.Fatalf("expected value to be %d, got %d", 5, l.value) t.Fatalf("expected value to be %d, got %d", 5, l.value)
} }
l.Allow(-2) l.AllowN(-2)
if l.value != 3 { if l.value != 3 {
t.Fatalf("expected value to be %d, got %d", 7, l.value) t.Fatalf("expected value to be %d, got %d", 7, l.value)
} }
@@ -34,17 +39,22 @@ func TestFixedLimiter_AddSub(t *testing.T) {
func TestBytesLimiter_Add_Simple(t *testing.T) { func TestBytesLimiter_Add_Simple(t *testing.T) {
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h
require.Nil(t, l.Allow(100*1024*1024)) require.True(t, l.AllowN(100*1024*1024))
require.Nil(t, l.Allow(100*1024*1024)) require.Equal(t, int64(100*1024*1024), l.Value())
require.Equal(t, ErrLimitReached, l.Allow(300*1024*1024))
require.True(t, l.AllowN(100*1024*1024))
require.Equal(t, int64(200*1024*1024), l.Value())
require.False(t, l.AllowN(300*1024*1024))
require.Equal(t, int64(200*1024*1024), l.Value())
} }
func TestBytesLimiter_Add_Wait(t *testing.T) { func TestBytesLimiter_Add_Wait(t *testing.T) {
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms) l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms)
require.Nil(t, l.Allow(250*1024*1024)) require.True(t, l.AllowN(250*1024*1024))
require.Equal(t, ErrLimitReached, l.Allow(400)) require.False(t, l.AllowN(400))
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
require.Nil(t, l.Allow(400)) require.True(t, l.AllowN(400))
} }
func TestLimitWriter_WriteNoLimiter(t *testing.T) { func TestLimitWriter_WriteNoLimiter(t *testing.T) {

View File

@@ -10,14 +10,14 @@ import (
// //
// Example: // Example:
// //
// lookup := func() (string, error) { // lookup := func() (string, error) {
// r, _ := http.Get("...") // r, _ := http.Get("...")
// s, _ := io.ReadAll(r.Body) // s, _ := io.ReadAll(r.Body)
// return string(s), nil // return string(s), nil
// } // }
// c := NewLookupCache[string](lookup, time.Hour) // c := NewLookupCache[string](lookup, time.Hour)
// fmt.Println(c.Get()) // Fetches the string via HTTP // fmt.Println(c.Get()) // Fetches the string via HTTP
// fmt.Println(c.Get()) // Uses cached value // fmt.Println(c.Get()) // Uses cached value
type LookupCache[T any] struct { type LookupCache[T any] struct {
value *T value *T
lookup func() (T, error) lookup func() (T, error)
@@ -26,8 +26,12 @@ type LookupCache[T any] struct {
mu sync.Mutex mu sync.Mutex
} }
// LookupFunc is a function that is called by the LookupCache if the underlying
// value is out-of-date. It returns the new value, or an error.
type LookupFunc[T any] func() (T, error)
// NewLookupCache creates a new LookupCache with a given time-to-live (TTL) // NewLookupCache creates a new LookupCache with a given time-to-live (TTL)
func NewLookupCache[T any](lookup func() (T, error), ttl time.Duration) *LookupCache[T] { func NewLookupCache[T any](lookup LookupFunc[T], ttl time.Duration) *LookupCache[T] {
return &LookupCache[T]{ return &LookupCache[T]{
value: nil, value: nil,
lookup: lookup, lookup: lookup,

View File

@@ -17,7 +17,7 @@ var (
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence // NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
// of that time from the current time (in UTC). // of that time from the current time (in UTC).
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time { func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
hour, minute, seconds := timeOfDay.Clock() hour, minute, seconds := timeOfDay.UTC().Clock()
now := base.UTC() now := base.UTC()
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC) next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC)
if next.Before(now) { if next.Before(now) {

View File

@@ -1,10 +1,12 @@
package util package util
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"golang.org/x/time/rate"
"io" "io"
"math/rand" "math/rand"
"net/netip" "net/netip"
@@ -107,13 +109,18 @@ func LastString(s []string, def string) string {
// RandomString returns a random string with a given length // RandomString returns a random string with a given length
func RandomString(length int) string { func RandomString(length int) string {
return RandomStringPrefix("", length)
}
// RandomStringPrefix returns a random string with a given length, with a prefix
func RandomStringPrefix(prefix string, length int) string {
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?! randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
defer randomMutex.Unlock() defer randomMutex.Unlock()
b := make([]byte, length) b := make([]byte, length-len(prefix))
for i := range b { for i := range b {
b[i] = randomStringCharset[random.Intn(len(randomStringCharset))] b[i] = randomStringCharset[random.Intn(len(randomStringCharset))]
} }
return string(b) return prefix + string(b)
} }
// ValidRandomString returns true if the given string matches the format created by RandomString // ValidRandomString returns true if the given string matches the format created by RandomString
@@ -216,6 +223,20 @@ func ParseSize(s string) (int64, error) {
} }
} }
// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB
func FormatSize(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d bytes", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the // ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
// input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing). // input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing).
func ReadPassword(in io.Reader) ([]byte, error) { func ReadPassword(in io.Reader) ([]byte, error) {
@@ -305,7 +326,7 @@ func UnmarshalJSON[T any](body io.ReadCloser) (*T, error) {
} }
// UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached // UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached
func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) { func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
defer r.Close() defer r.Close()
p, err := Peek(r, limit) p, err := Peek(r, limit)
if err != nil { if err != nil {
@@ -314,8 +335,56 @@ func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
return nil, ErrTooLargeJSON return nil, ErrTooLargeJSON
} }
var obj T var obj T
if err := json.NewDecoder(p).Decode(&obj); err != nil { if len(bytes.TrimSpace(p.PeekedBytes)) == 0 && allowEmpty {
return &obj, nil
} else if err := json.NewDecoder(p).Decode(&obj); err != nil {
return nil, ErrUnmarshalJSON return nil, ErrUnmarshalJSON
} }
return &obj, nil return &obj, nil
} }
// Retry executes function f until if succeeds, and then returns t. If f fails, it sleeps
// and tries again. The sleep durations are passed as the after params.
func Retry[T any](f func() (*T, error), after ...time.Duration) (t *T, err error) {
for _, delay := range after {
if t, err = f(); err == nil {
return t, nil
}
time.Sleep(delay)
}
return nil, err
}
// MinMax returns value if it is between min and max, or either
// min or max if it is out of range
func MinMax[T int | int64](value, min, max T) T {
if value < min {
return min
} else if value > max {
return max
}
return value
}
// Max returns the maximum value of the two given values
func Max[T int | int64 | rate.Limit](a, b T) T {
if a > b {
return a
}
return b
}
// String turns a string into a pointer of a string
func String(v string) *string {
return &v
}
// Int turns an int into a pointer of an int
func Int(v int) *int {
return &v
}
// Time turns a time.Time into a pointer
func Time(v time.Time) *time.Time {
return &v
}

View File

@@ -1,12 +1,15 @@
package util package util
import ( import (
"errors"
"golang.org/x/time/rate"
"io" "io"
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -190,13 +193,79 @@ func TestReadJSON_Failure(t *testing.T) {
} }
func TestReadJSONWithLimit_Success(t *testing.T) { func TestReadJSONWithLimit_Success(t *testing.T) {
v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100) v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100, false)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "some name", v.Name) require.Equal(t, "some name", v.Name)
require.Equal(t, 99, v.Something) require.Equal(t, 99, v.Something)
} }
func TestReadJSONWithLimit_FailureTooLong(t *testing.T) { func TestReadJSONWithLimit_FailureTooLong(t *testing.T) {
_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10) _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10, false)
require.Equal(t, ErrTooLargeJSON, err) require.Equal(t, ErrTooLargeJSON, err)
} }
func TestReadJSONWithLimit_AllowEmpty(t *testing.T) {
v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, true)
require.Nil(t, err)
require.Equal(t, "", v.Name)
require.Equal(t, 0, v.Something)
}
func TestReadJSONWithLimit_NoAllowEmpty(t *testing.T) {
_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, false)
require.Equal(t, ErrUnmarshalJSON, err)
}
func TestRetry_Succeeds(t *testing.T) {
start := time.Now()
delays, i := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, time.Second}, 0
fn := func() (*int, error) {
i++
if i < len(delays) {
return nil, errors.New("error")
}
return Int(99), nil
}
result, err := Retry[int](fn, delays...)
require.Nil(t, err)
require.Equal(t, 99, *result)
require.True(t, time.Since(start).Milliseconds() > 150)
}
func TestRetry_Fails(t *testing.T) {
fn := func() (*int, error) {
return nil, errors.New("fails")
}
_, err := Retry[int](fn, 10*time.Millisecond)
require.Error(t, err)
}
func TestMinMax(t *testing.T) {
require.Equal(t, 10, MinMax(9, 10, 99))
require.Equal(t, 99, MinMax(100, 10, 99))
require.Equal(t, 50, MinMax(50, 10, 99))
}
func TestMax(t *testing.T) {
require.Equal(t, 9, Max(1, 9))
require.Equal(t, 9, Max(9, 1))
require.Equal(t, rate.Every(time.Minute), Max(rate.Every(time.Hour), rate.Every(time.Minute)))
}
func TestPointerFunctions(t *testing.T) {
i, s, ti := Int(99), String("abc"), Time(time.Unix(99, 0))
require.Equal(t, 99, *i)
require.Equal(t, "abc", *s)
require.Equal(t, time.Unix(99, 0), *ti)
}
func TestMaybeMarshalJSON(t *testing.T) {
require.Equal(t, `"aa"`, MaybeMarshalJSON("aa"))
require.Equal(t, `[
"aa",
"bb"
]`, MaybeMarshalJSON([]string{"aa", "bb"}))
require.Equal(t, "<cannot serialize>", MaybeMarshalJSON(func() {}))
require.Equal(t, `"`+strings.Repeat("x", 4999), MaybeMarshalJSON(strings.Repeat("x", 6000)))
}

13231
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,11 @@
// During web development, you may change values here for rapid testing. // During web development, you may change values here for rapid testing.
var config = { var config = {
base_url: "http://localhost:2586", // window.location.origin FIXME update before merging base_url: window.location.origin, // Set this to "https://127.0.0.1" to test against a different server
app_root: "/app", app_root: "/app",
enable_login: true, enable_login: true,
enable_signup: true, enable_signup: true,
enable_payments: true, enable_payments: true,
enable_reservations: true, enable_reservations: true,
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"] disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
}; };

View File

@@ -4,8 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ntfy web</title> <title>ntfy web</title>
<link rel="stylesheet" href="static/css/home.css" type="text/css">
<!-- Mobile view --> <!-- Mobile view -->
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
@@ -31,7 +29,8 @@
<!-- Never index --> <!-- Never index -->
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<!-- Fonts --> <!-- Style overrides & fonts -->
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css">
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css"> <link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
</head> </head>
<body> <body>

View File

@@ -0,0 +1,10 @@
/* web app styling overrides */
a, a:visited {
color: #338574;
}
a:hover {
text-decoration: none;
color: #317f6f;
}

View File

@@ -1,6 +1,6 @@
/* general styling */ /* general styling */
#site { html, body {
font-family: 'Roboto', sans-serif; font-family: 'Roboto', sans-serif;
font-weight: 400; font-weight: 400;
font-size: 1.1em; font-size: 1.1em;
@@ -9,16 +9,22 @@
padding: 0; padding: 0;
} }
#site a, a:visited { html {
/* prevent scrollbar from repositioning website:
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
overflow-y: scroll;
}
a, a:visited {
color: #338574; color: #338574;
} }
#site a:hover { a:hover {
text-decoration: none; text-decoration: none;
color: #317f6f; color: #317f6f;
} }
#site h1 { h1 {
margin-top: 35px; margin-top: 35px;
margin-bottom: 30px; margin-bottom: 30px;
font-size: 2.5em; font-size: 2.5em;
@@ -28,7 +34,7 @@
color: #666; color: #666;
} }
#site h2 { h2 {
margin-top: 30px; margin-top: 30px;
margin-bottom: 5px; margin-bottom: 5px;
font-size: 1.8em; font-size: 1.8em;
@@ -36,7 +42,7 @@
color: #333; color: #333;
} }
#site h3 { h3 {
margin-top: 25px; margin-top: 25px;
margin-bottom: 5px; margin-bottom: 5px;
font-size: 1.3em; font-size: 1.3em;
@@ -44,28 +50,28 @@
color: #333; color: #333;
} }
#site p { p {
margin-top: 10px; margin-top: 10px;
margin-bottom: 20px; margin-bottom: 20px;
line-height: 160%; line-height: 160%;
font-weight: 400; font-weight: 400;
} }
#site p.smallMarginBottom { p.smallMarginBottom {
margin-bottom: 10px; margin-bottom: 10px;
} }
#site b { b {
font-weight: 500; font-weight: 500;
} }
#site tt { tt {
background: #eee; background: #eee;
padding: 2px 7px; padding: 2px 7px;
border-radius: 3px; border-radius: 3px;
} }
#site code { code {
display: block; display: block;
background: #eee; background: #eee;
font-family: monospace; font-family: monospace;
@@ -79,18 +85,18 @@
/* Main page */ /* Main page */
#site #main { #main {
max-width: 900px; max-width: 900px;
margin: 0 auto 50px auto; margin: 0 auto 50px auto;
padding: 0 10px; padding: 0 10px;
} }
#site #error { #error {
color: darkred; color: darkred;
font-style: italic; font-style: italic;
} }
#site #ironicCenterTagDontFreakOut { #ironicCenterTagDontFreakOut {
color: #666; color: #666;
} }
@@ -114,22 +120,22 @@
/* Figures */ /* Figures */
#site figure { figure {
text-align: center; text-align: center;
} }
#site figure img, figure video { figure img, figure video {
filter: drop-shadow(3px 3px 3px #ccc); filter: drop-shadow(3px 3px 3px #ccc);
border-radius: 7px; border-radius: 7px;
max-width: 100%; max-width: 100%;
} }
#site figure video { figure video {
width: 100%; width: 100%;
max-height: 450px; max-height: 450px;
} }
#site figcaption { figcaption {
text-align: center; text-align: center;
font-style: italic; font-style: italic;
padding-top: 10px; padding-top: 10px;
@@ -137,18 +143,18 @@
/* Screenshots */ /* Screenshots */
#site #screenshots { #screenshots {
text-align: center; text-align: center;
} }
#site #screenshots img { #screenshots img {
height: 190px; height: 190px;
margin: 3px; margin: 3px;
border-radius: 5px; border-radius: 5px;
filter: drop-shadow(2px 2px 2px #ddd); filter: drop-shadow(2px 2px 2px #ddd);
} }
#site #screenshots .nowrap { #screenshots .nowrap {
white-space: nowrap; white-space: nowrap;
} }
@@ -214,60 +220,52 @@
/* Header */ /* Header */
#site #header { #header {
background: #338574; background: #338574;
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc); height: 130px;
height: 70px;
} }
#site #header #headerBox { #header #headerBox {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 0 10px; padding: 0 10px;
} }
#site #header #logo { #header #logo {
margin-top: 14px; margin-top: 23px;
width: 48px;
float: left; float: left;
} }
#site #header #name { #header #name {
float: left; float: left;
color: white; color: white;
font-size: 1.7em; font-size: 2.6em;
font-weight: 400; font-weight: 300;
margin: 12px 0 0 10px; margin: 35px 0 0 20px;
} }
#site #header #menu { #header ol {
list-style-type: none; list-style-type: none;
float: right; float: right;
margin-top: 16px; margin-top: 80px;
} }
#site #header #menu li { #header ol li {
display: inline-block; display: inline-block;
padding: 3px 10px; margin: 0 10px;
font-weight: 400; font-weight: 400;
border-radius: 5px;
} }
#site #header #menu li { #header ol li a, nav ol li a:visited {
font-size: 1em;
}
#site #header #menu li a,
#site #header #menu li a:visited {
color: white; color: white;
text-decoration: none; text-decoration: none;
} }
#site #header #menu li:hover { #header ol li a:hover {
background: #3f9a86; text-decoration: underline;
} }
#site li { li {
padding: 4px 0; padding: 4px 0;
margin: 4px 0; margin: 4px 0;
font-size: 0.9em; font-size: 0.9em;
@@ -276,7 +274,7 @@
/* Hide top menu SMALL SCREEN */ /* Hide top menu SMALL SCREEN */
@media only screen and (max-width: 780px) { @media only screen and (max-width: 780px) {
#header #menu { #header ol {
display: none; display: none;
} }
} }

View File

@@ -0,0 +1,45 @@
{
"action_bar_logo_alt": "شعار ntfy",
"action_bar_settings": "اﻹعدادات",
"action_bar_clear_notifications": "محو كافة الإشعارات",
"action_bar_unsubscribe": "إلغاء الاشتراك",
"message_bar_show_dialog": "إظهار مربع حوار النشر",
"message_bar_publish": "نشر الرسالة",
"nav_topics_title": "المواضيع التي تم الاشتراك فيها",
"nav_button_all_notifications": "كافة الإشعارات",
"nav_button_settings": "اﻹعدادات",
"nav_button_documentation": "الدليل",
"nav_button_publish_message": "نشر الإشعار",
"nav_button_subscribe": "اشترك في الموضوع",
"nav_button_connecting": "جارٍ الاتصال",
"alert_grant_title": "تم تعطيل الإشعارات",
"alert_grant_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.",
"notifications_list": "قائمة الإشعارات",
"notifications_list_item": "إشعار",
"notifications_mark_read": "وضع علامة كمقروء",
"notifications_tags": "الوسوم",
"notifications_priority_x": "الأولوية {{priority}}",
"notifications_new_indicator": "إشعار جديد",
"notifications_attachment_image": "صورة مرفقة",
"notifications_attachment_copy_url_button": "نسخ عنوان URL",
"notifications_attachment_open_title": "انتقل إلى {{url}}",
"notifications_attachment_link_expires": "تنتهي صلاحية الرابط {{date}}",
"notifications_attachment_link_expired": "انتهت صلاحية رابط التنزيل",
"notifications_attachment_file_image": "ملف الصورة",
"notifications_attachment_file_video": "ملف فيديو",
"notifications_attachment_file_audio": "ملف صوتي",
"notifications_attachment_file_app": "ملف تطبيق Android",
"notifications_attachment_file_document": "وثيقة أخرى",
"notifications_click_copy_url_button": "نسخ الرابط",
"notifications_click_open_button": "فتح الرابط",
"notifications_actions_open_url_title": "انتقل إلى {{url}}",
"notifications_actions_not_supported": "هذا الإجراء غير مدعوم في تطبيق الويب",
"action_bar_send_test_notification": "إرسال إشعار للاختبار",
"action_bar_show_menu": "عرض القائمة",
"message_bar_type_message": "اكتب رسالة هنا",
"alert_not_supported_title": "الإشعارات غير مدعومة",
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
"notifications_delete": "حذف",
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة"
}

View File

@@ -114,8 +114,8 @@
"prefs_users_table_user_header": "Потребител", "prefs_users_table_user_header": "Потребител",
"prefs_users_dialog_title_edit": "Промяна на потребител", "prefs_users_dialog_title_edit": "Промяна на потребител",
"prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh", "prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh",
"prefs_users_dialog_button_cancel": "Отказ", "common_cancel": "Отказ",
"prefs_users_dialog_button_save": "Запазване", "common_save": "Запазване",
"prefs_appearance_language_title": "Език", "prefs_appearance_language_title": "Език",
"subscribe_dialog_login_password_label": "Парола", "subscribe_dialog_login_password_label": "Парола",
"subscribe_dialog_login_button_login": "Вход", "subscribe_dialog_login_button_login": "Вход",
@@ -128,7 +128,7 @@
"prefs_users_dialog_title_add": "Добавяне на потребител", "prefs_users_dialog_title_add": "Добавяне на потребител",
"prefs_notifications_delete_after_one_month": "След един месец", "prefs_notifications_delete_after_one_month": "След един месец",
"prefs_users_dialog_username_label": "Потребител, напр. phil", "prefs_users_dialog_username_label": "Потребител, напр. phil",
"prefs_users_dialog_button_add": "Добавяне", "common_add": "Добавяне",
"error_boundary_title": "О, не, ntfy се срина", "error_boundary_title": "О, не, ntfy се срина",
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.", "error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Следа от стека", "error_boundary_stack_trace": "Следа от стека",

View File

@@ -116,9 +116,9 @@
"prefs_users_add_button": "Přidat uživatele", "prefs_users_add_button": "Přidat uživatele",
"prefs_users_table_user_header": "Uživatel", "prefs_users_table_user_header": "Uživatel",
"prefs_users_table_base_url_header": "URL služby", "prefs_users_table_base_url_header": "URL služby",
"prefs_users_dialog_button_cancel": "Zrušit", "common_cancel": "Zrušit",
"prefs_users_dialog_button_add": "Přidat", "common_add": "Přidat",
"prefs_users_dialog_button_save": "Uložit", "common_save": "Uložit",
"priority_min": "nejnižší", "priority_min": "nejnižší",
"priority_low": "nízká", "priority_low": "nízká",
"priority_default": "výchozí", "priority_default": "výchozí",

View File

@@ -15,9 +15,9 @@
"prefs_notifications_min_priority_max_only": "Nur höchste Priorität", "prefs_notifications_min_priority_max_only": "Nur höchste Priorität",
"prefs_notifications_delete_after_never": "Nie", "prefs_notifications_delete_after_never": "Nie",
"prefs_users_dialog_password_label": "Kennwort", "prefs_users_dialog_password_label": "Kennwort",
"prefs_users_dialog_button_cancel": "Abbrechen", "common_cancel": "Abbrechen",
"prefs_users_dialog_button_add": "Hinzufügen", "common_add": "Hinzufügen",
"prefs_users_dialog_button_save": "Speichern", "common_save": "Speichern",
"prefs_appearance_language_title": "Sprache", "prefs_appearance_language_title": "Sprache",
"notifications_none_for_any_description": "Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.", "notifications_none_for_any_description": "Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.",
"publish_dialog_message_placeholder": "Gib hier eine Nachricht ein", "publish_dialog_message_placeholder": "Gib hier eine Nachricht ein",

View File

@@ -1,4 +1,7 @@
{ {
"common_cancel": "Cancel",
"common_save": "Save",
"common_add": "Add",
"signup_title": "Create a ntfy account", "signup_title": "Create a ntfy account",
"signup_form_username": "Username", "signup_form_username": "Username",
"signup_form_password": "Password", "signup_form_password": "Password",
@@ -9,15 +12,19 @@
"signup_disabled": "Signup is disabled", "signup_disabled": "Signup is disabled",
"signup_error_username_taken": "Username {{username}} is already taken", "signup_error_username_taken": "Username {{username}} is already taken",
"signup_error_creation_limit_reached": "Account creation limit reached", "signup_error_creation_limit_reached": "Account creation limit reached",
"signup_error_unknown": "Unknown error. Check logs for details.",
"login_title": "Sign in to your ntfy account", "login_title": "Sign in to your ntfy account",
"login_form_button_submit": "Sign in", "login_form_button_submit": "Sign in",
"login_link_signup": "Sign up", "login_link_signup": "Sign up",
"login_disabled": "Login is disabled",
"action_bar_show_menu": "Show menu", "action_bar_show_menu": "Show menu",
"action_bar_logo_alt": "ntfy logo", "action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Settings", "action_bar_settings": "Settings",
"action_bar_account": "Account", "action_bar_account": "Account",
"action_bar_subscription_settings": "Subscription settings", "action_bar_change_display_name": "Change display name",
"action_bar_reservation_add": "Reserve topic",
"action_bar_reservation_edit": "Change reservation",
"action_bar_reservation_delete": "Remove reservation",
"action_bar_reservation_limit_reached": "Limit reached",
"action_bar_send_test_notification": "Send test notification", "action_bar_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications", "action_bar_clear_notifications": "Clear all notifications",
"action_bar_unsubscribe": "Unsubscribe", "action_bar_unsubscribe": "Unsubscribe",
@@ -41,6 +48,8 @@
"nav_button_subscribe": "Subscribe to topic", "nav_button_subscribe": "Subscribe to topic",
"nav_button_muted": "Notifications muted", "nav_button_muted": "Notifications muted",
"nav_button_connecting": "connecting", "nav_button_connecting": "connecting",
"nav_upgrade_banner_label": "Upgrade to ntfy Pro",
"nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
"alert_grant_title": "Notifications are disabled", "alert_grant_title": "Notifications are disabled",
"alert_grant_description": "Grant your browser permission to display desktop notifications.", "alert_grant_description": "Grant your browser permission to display desktop notifications.",
"alert_grant_button": "Grant now", "alert_grant_button": "Grant now",
@@ -81,12 +90,10 @@
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.", "notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
"notifications_example": "Example", "notifications_example": "Example",
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.", "notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
"subscription_settings_dialog_title": "Subscription settings", "display_name_dialog_title": "Change display name",
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.", "display_name_dialog_description": "Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.",
"subscription_settings_dialog_display_name_placeholder": "Display name", "display_name_dialog_placeholder": "Display name",
"subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access", "reserve_dialog_checkbox_label": "Reserve topic and configure access",
"subscription_settings_button_cancel": "Cancel",
"subscription_settings_button_save": "Save",
"notifications_loading": "Loading notifications …", "notifications_loading": "Loading notifications …",
"publish_dialog_title_topic": "Publish to {{topic}}", "publish_dialog_title_topic": "Publish to {{topic}}",
"publish_dialog_title_no_topic": "Publish notification", "publish_dialog_title_no_topic": "Publish notification",
@@ -170,35 +177,40 @@
"account_basics_password_title": "Password", "account_basics_password_title": "Password",
"account_basics_password_description": "Change your account password", "account_basics_password_description": "Change your account password",
"account_basics_password_dialog_title": "Change password", "account_basics_password_dialog_title": "Change password",
"account_basics_password_dialog_current_password_label": "Current password",
"account_basics_password_dialog_new_password_label": "New password", "account_basics_password_dialog_new_password_label": "New password",
"account_basics_password_dialog_confirm_password_label": "Confirm password", "account_basics_password_dialog_confirm_password_label": "Confirm password",
"account_basics_password_dialog_button_cancel": "Cancel",
"account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_button_submit": "Change password",
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
"account_usage_title": "Usage", "account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}", "account_usage_of_limit": "of {{limit}}",
"account_usage_unlimited": "Unlimited", "account_usage_unlimited": "Unlimited",
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)", "account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
"account_usage_tier_title": "Account type", "account_basics_tier_title": "Account type",
"account_usage_tier_description": "Your account's power level", "account_basics_tier_description": "Your account's power level",
"account_usage_tier_admin": "Admin", "account_basics_tier_admin": "Admin",
"account_usage_tier_basic": "Basic", "account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
"account_usage_tier_free": "Free", "account_basics_tier_admin_suffix_no_tier": "(no tier)",
"account_usage_tier_upgrade_button": "Upgrade to Pro", "account_basics_tier_basic": "Basic",
"account_usage_tier_change_button": "Change", "account_basics_tier_free": "Free",
"account_usage_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew", "account_basics_tier_upgrade_button": "Upgrade to Pro",
"account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.", "account_basics_tier_change_button": "Change",
"account_usage_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.", "account_basics_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
"account_usage_manage_billing_button": "Manage billing", "account_basics_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
"account_basics_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.",
"account_basics_tier_manage_billing_button": "Manage billing",
"account_usage_messages_title": "Published messages", "account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent", "account_usage_emails_title": "Emails sent",
"account_usage_reservations_title": "Reserved topics", "account_usage_reservations_title": "Reserved topics",
"account_usage_reservations_none": "No reserved topics for this account",
"account_usage_attachment_storage_title": "Attachment storage", "account_usage_attachment_storage_title": "Attachment storage",
"account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}", "account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}",
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.", "account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
"account_usage_cannot_create_portal_session": "Unable to open billing portal",
"account_delete_title": "Delete account", "account_delete_title": "Delete account",
"account_delete_description": "Permanently delete your account", "account_delete_description": "Permanently delete your account",
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please type '{{username}}' in the text box below.", "account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. After deletion, your username will be unavailable for 7 days. If you really want to proceed, please confirm with your password in the box below.",
"account_delete_dialog_label": "Type '{{username}}' to delete account", "account_delete_dialog_label": "Password",
"account_delete_dialog_button_cancel": "Cancel", "account_delete_dialog_button_cancel": "Cancel",
"account_delete_dialog_button_submit": "Permanently delete account", "account_delete_dialog_button_submit": "Permanently delete account",
"account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.", "account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.",
@@ -219,6 +231,34 @@
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe", "account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription", "account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
"account_upgrade_dialog_button_update_subscription": "Update subscription", "account_upgrade_dialog_button_update_subscription": "Update subscription",
"account_tokens_title": "Access tokens",
"account_tokens_description": "Use access tokens when publishing and subscribing via the ntfy API, so you don't have to send your account credentials. Check out the <Link>documentation</Link> to learn more.",
"account_tokens_table_token_header": "Token",
"account_tokens_table_label_header": "Label",
"account_tokens_table_last_access_header": "Last access",
"account_tokens_table_expires_header": "Expires",
"account_tokens_table_never_expires": "Never expires",
"account_tokens_table_current_session": "Current browser session",
"account_tokens_table_copy_to_clipboard": "Copy to clipboard",
"account_tokens_table_copied_to_clipboard": "Access token copied",
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
"account_tokens_table_create_token_button": "Create access token",
"account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup",
"account_tokens_dialog_title_create": "Create access token",
"account_tokens_dialog_title_edit": "Edit access token",
"account_tokens_dialog_title_delete": "Delete access token",
"account_tokens_dialog_label": "Label, e.g. Radarr notifications",
"account_tokens_dialog_button_create": "Create token",
"account_tokens_dialog_button_update": "Update token",
"account_tokens_dialog_button_cancel": "Cancel",
"account_tokens_dialog_expires_label": "Access token expires in",
"account_tokens_dialog_expires_unchanged": "Leave expiry date unchanged",
"account_tokens_dialog_expires_x_hours": "Token expires in {{hours}} hours",
"account_tokens_dialog_expires_x_days": "Token expires in {{days}} days",
"account_tokens_dialog_expires_never": "Token never expires",
"account_tokens_delete_dialog_title": "Delete access token",
"account_tokens_delete_dialog_description": "Before deleting an access token, be sure that no applications or scripts are actively using it. <strong>This action cannot be undone</strong>.",
"account_tokens_delete_dialog_submit_button": "Permanently delete token",
"prefs_notifications_title": "Notifications", "prefs_notifications_title": "Notifications",
"prefs_notifications_sound_title": "Notification sound", "prefs_notifications_sound_title": "Notification sound",
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive", "prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
@@ -260,9 +300,6 @@
"prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh", "prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh",
"prefs_users_dialog_username_label": "Username, e.g. phil", "prefs_users_dialog_username_label": "Username, e.g. phil",
"prefs_users_dialog_password_label": "Password", "prefs_users_dialog_password_label": "Password",
"prefs_users_dialog_button_cancel": "Cancel",
"prefs_users_dialog_button_add": "Add",
"prefs_users_dialog_button_save": "Save",
"prefs_appearance_title": "Appearance", "prefs_appearance_title": "Appearance",
"prefs_appearance_language_title": "Language", "prefs_appearance_language_title": "Language",
"prefs_reservations_title": "Reserved topics", "prefs_reservations_title": "Reserved topics",
@@ -278,11 +315,20 @@
"prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe", "prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe",
"prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish", "prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish",
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
"prefs_reservations_table_not_subscribed": "Not subscribed",
"prefs_reservations_table_click_to_subscribe": "Click to subscribe",
"prefs_reservations_dialog_title_add": "Reserve topic", "prefs_reservations_dialog_title_add": "Reserve topic",
"prefs_reservations_dialog_title_edit": "Edit reserved topic", "prefs_reservations_dialog_title_edit": "Edit reserved topic",
"prefs_reservations_dialog_title_delete": "Delete topic reservation",
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.", "prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_dialog_topic_label": "Topic", "prefs_reservations_dialog_topic_label": "Topic",
"prefs_reservations_dialog_access_label": "Access", "prefs_reservations_dialog_access_label": "Access",
"reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.",
"reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments",
"reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",
"reservation_delete_dialog_action_delete_title": "Delete cached messages and attachments",
"reservation_delete_dialog_action_delete_description": "Cached messages and attachments will be permanently deleted. This action cannot be undone.",
"reservation_delete_dialog_submit_button": "Delete reservation",
"priority_min": "min", "priority_min": "min",
"priority_low": "low", "priority_low": "low",
"priority_default": "default", "priority_default": "default",

View File

@@ -101,8 +101,8 @@
"prefs_users_add_button": "Añadir usuario", "prefs_users_add_button": "Añadir usuario",
"prefs_users_dialog_title_edit": "Editar usuario", "prefs_users_dialog_title_edit": "Editar usuario",
"prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh", "prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh",
"prefs_users_dialog_button_add": "Añadir", "common_add": "Añadir",
"prefs_users_dialog_button_save": "Guardar", "common_save": "Guardar",
"prefs_appearance_title": "Apariencia", "prefs_appearance_title": "Apariencia",
"prefs_appearance_language_title": "Idioma", "prefs_appearance_language_title": "Idioma",
"error_boundary_title": "Oh no, ntfy tuvo un error", "error_boundary_title": "Oh no, ntfy tuvo un error",
@@ -134,7 +134,7 @@
"prefs_users_dialog_password_label": "Contraseña", "prefs_users_dialog_password_label": "Contraseña",
"error_boundary_description": "Obviamente, esto no debería ocurrir. Lo sentimos mucho.<br/>Si tienes un minuto, por favor <githubLink>informa de esto en GitHub</githubLink>, o avísanos vía <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.", "error_boundary_description": "Obviamente, esto no debería ocurrir. Lo sentimos mucho.<br/>Si tienes un minuto, por favor <githubLink>informa de esto en GitHub</githubLink>, o avísanos vía <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
"prefs_users_dialog_title_add": "Añadir usuario", "prefs_users_dialog_title_add": "Añadir usuario",
"prefs_users_dialog_button_cancel": "Cancelar", "common_cancel": "Cancelar",
"prefs_users_dialog_username_label": "Nombre de usuario, ej. phil", "prefs_users_dialog_username_label": "Nombre de usuario, ej. phil",
"priority_max": "máx", "priority_max": "máx",
"priority_high": "alta", "priority_high": "alta",

View File

@@ -7,7 +7,7 @@
"message_bar_type_message": "Tapez un message ici", "message_bar_type_message": "Tapez un message ici",
"notifications_attachment_open_button": "Ouvrir la pièce jointe", "notifications_attachment_open_button": "Ouvrir la pièce jointe",
"notifications_attachment_link_expires": "le lien expire {{date}}", "notifications_attachment_link_expires": "le lien expire {{date}}",
"message_bar_error_publishing": "Notification d'erreur de publication", "message_bar_error_publishing": "Erreur lors de la publication de la notification",
"nav_button_all_notifications": "Toutes les notifications", "nav_button_all_notifications": "Toutes les notifications",
"nav_button_settings": "Paramètres", "nav_button_settings": "Paramètres",
"nav_button_documentation": "Documentation", "nav_button_documentation": "Documentation",
@@ -79,8 +79,8 @@
"subscribe_dialog_subscribe_title": "S'abonner au sujet", "subscribe_dialog_subscribe_title": "S'abonner au sujet",
"subscribe_dialog_login_title": "Connexion nécessaire", "subscribe_dialog_login_title": "Connexion nécessaire",
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus", "prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
"prefs_users_dialog_button_cancel": "Annuler", "common_cancel": "Annuler",
"error_boundary_button_copy_stack_trace": "Copier la stack strace", "error_boundary_button_copy_stack_trace": "Copier la trace d'appels",
"publish_dialog_attached_file_title": "Fichier joint :", "publish_dialog_attached_file_title": "Fichier joint :",
"publish_dialog_checkbox_publish_another": "Publier un autre", "publish_dialog_checkbox_publish_another": "Publier un autre",
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint", "publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
@@ -128,8 +128,8 @@
"prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.", "prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.",
"prefs_users_table_user_header": "Utilisateur", "prefs_users_table_user_header": "Utilisateur",
"prefs_users_dialog_title_edit": "Éditer l'utilisateur", "prefs_users_dialog_title_edit": "Éditer l'utilisateur",
"prefs_users_dialog_button_add": "Ajouter", "common_add": "Ajouter",
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.", "error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"prefs_users_dialog_title_add": "Ajouter un utilisateur", "prefs_users_dialog_title_add": "Ajouter un utilisateur",
"error_boundary_stack_trace": "Trace de pile d'appels", "error_boundary_stack_trace": "Trace de pile d'appels",
"error_boundary_gathering_info": "Récupérer plus d'information…", "error_boundary_gathering_info": "Récupérer plus d'information…",
@@ -152,7 +152,7 @@
"publish_dialog_chip_topic_label": "Changer de sujet", "publish_dialog_chip_topic_label": "Changer de sujet",
"publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.", "publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.",
"publish_dialog_button_cancel_sending": "Annuler l'envoi", "publish_dialog_button_cancel_sending": "Annuler l'envoi",
"prefs_users_dialog_button_save": "Enregistrer", "common_save": "Enregistrer",
"notifications_new_indicator": "Nouvelle notification", "notifications_new_indicator": "Nouvelle notification",
"publish_dialog_delay_reset": "Retirer le délai de réception", "publish_dialog_delay_reset": "Retirer le délai de réception",
"notifications_list_item": "Notification", "notifications_list_item": "Notification",

View File

@@ -108,7 +108,7 @@
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése", "prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi", "prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
"prefs_users_dialog_password_label": "Jelszó", "prefs_users_dialog_password_label": "Jelszó",
"prefs_users_dialog_button_add": "Hozzáadás", "common_add": "Hozzáadás",
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh", "prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
"notifications_loading": "Értesítések betöltése …", "notifications_loading": "Értesítések betöltése …",
"publish_dialog_progress_uploading": "Feltöltés …", "publish_dialog_progress_uploading": "Feltöltés …",
@@ -144,8 +144,8 @@
"error_boundary_gathering_info": "Több információ…", "error_boundary_gathering_info": "Több információ…",
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})", "publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
"prefs_users_title": "Felhasználók kezelése", "prefs_users_title": "Felhasználók kezelése",
"prefs_users_dialog_button_cancel": "Mégsem", "common_cancel": "Mégsem",
"prefs_users_dialog_button_save": "Mentés", "common_save": "Mentés",
"prefs_users_dialog_title_add": "Felhasználó hozzáadása", "prefs_users_dialog_title_add": "Felhasználó hozzáadása",
"prefs_appearance_language_title": "Nyelv", "prefs_appearance_language_title": "Nyelv",
"priority_low": "alacsony", "priority_low": "alacsony",

View File

@@ -19,7 +19,7 @@
"publish_dialog_message_label": "Pesan", "publish_dialog_message_label": "Pesan",
"nav_button_settings": "Pengaturan", "nav_button_settings": "Pengaturan",
"nav_button_documentation": "Dokumentasi", "nav_button_documentation": "Dokumentasi",
"prefs_users_dialog_button_add": "Tambahkan", "common_add": "Tambahkan",
"nav_topics_title": "Topik yang dilanggani", "nav_topics_title": "Topik yang dilanggani",
"nav_button_subscribe": "Berlangganan ke topik", "nav_button_subscribe": "Berlangganan ke topik",
"alert_grant_title": "Notifikasi dinonaktifkan", "alert_grant_title": "Notifikasi dinonaktifkan",
@@ -113,7 +113,7 @@
"prefs_notifications_sound_no_sound": "Tidak ada suara", "prefs_notifications_sound_no_sound": "Tidak ada suara",
"prefs_users_table_user_header": "Pengguna", "prefs_users_table_user_header": "Pengguna",
"prefs_users_dialog_base_url_label": "URL Layanan, mis. https://ntfy.sh", "prefs_users_dialog_base_url_label": "URL Layanan, mis. https://ntfy.sh",
"prefs_users_dialog_button_save": "Simpan", "common_save": "Simpan",
"prefs_appearance_title": "Tampilan", "prefs_appearance_title": "Tampilan",
"subscribe_dialog_login_password_label": "Kata sandi", "subscribe_dialog_login_password_label": "Kata sandi",
"subscribe_dialog_login_button_back": "Kembali", "subscribe_dialog_login_button_back": "Kembali",
@@ -131,7 +131,7 @@
"prefs_users_dialog_title_add": "Tambahkan pengguna", "prefs_users_dialog_title_add": "Tambahkan pengguna",
"prefs_users_dialog_title_edit": "Edit pengguna", "prefs_users_dialog_title_edit": "Edit pengguna",
"prefs_users_dialog_password_label": "Kata sandi", "prefs_users_dialog_password_label": "Kata sandi",
"prefs_users_dialog_button_cancel": "Batal", "common_cancel": "Batal",
"error_boundary_title": "Aduh, ntfy mogok", "error_boundary_title": "Aduh, ntfy mogok",
"error_boundary_description": "Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.<br/>Jika Anda punya beberapa menit, silakan <githubLink>laporkan ini di GitHub</githubLink>, atau beritahu kami melalui <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.", "error_boundary_description": "Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.<br/>Jika Anda punya beberapa menit, silakan <githubLink>laporkan ini di GitHub</githubLink>, atau beritahu kami melalui <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Jejak tumpukan", "error_boundary_stack_trace": "Jejak tumpukan",

View File

@@ -134,9 +134,9 @@
"prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh", "prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh",
"prefs_users_dialog_username_label": "Nome utente, ad es. phil", "prefs_users_dialog_username_label": "Nome utente, ad es. phil",
"prefs_users_dialog_password_label": "Password", "prefs_users_dialog_password_label": "Password",
"prefs_users_dialog_button_cancel": "Annulla", "common_cancel": "Annulla",
"prefs_users_dialog_button_add": "Aggiungere", "common_add": "Aggiungere",
"prefs_users_dialog_button_save": "Salva", "common_save": "Salva",
"prefs_appearance_title": "Aspetto", "prefs_appearance_title": "Aspetto",
"prefs_appearance_language_title": "Lingua", "prefs_appearance_language_title": "Lingua",
"priority_min": "min", "priority_min": "min",

View File

@@ -99,7 +99,7 @@
"prefs_notifications_delete_after_three_hours": "3時間後", "prefs_notifications_delete_after_three_hours": "3時間後",
"prefs_users_description": "保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。", "prefs_users_description": "保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。",
"prefs_users_add_button": "ユーザー追加", "prefs_users_add_button": "ユーザー追加",
"prefs_users_dialog_button_add": "追加", "common_add": "追加",
"subscribe_dialog_subscribe_use_another_label": "他のサーバーを使用", "subscribe_dialog_subscribe_use_another_label": "他のサーバーを使用",
"subscribe_dialog_error_user_not_authorized": "ユーザー名 {{username}} は許可されていません", "subscribe_dialog_error_user_not_authorized": "ユーザー名 {{username}} は許可されていません",
"prefs_notifications_delete_after_one_week": "1週間後", "prefs_notifications_delete_after_one_week": "1週間後",
@@ -118,8 +118,8 @@
"prefs_notifications_min_priority_title": "表示する優先度", "prefs_notifications_min_priority_title": "表示する優先度",
"prefs_notifications_min_priority_default_and_higher": "優先度通常 およびそれ以上", "prefs_notifications_min_priority_default_and_higher": "優先度通常 およびそれ以上",
"prefs_notifications_delete_after_title": "通知を削除", "prefs_notifications_delete_after_title": "通知を削除",
"prefs_users_dialog_button_cancel": "キャンセル", "common_cancel": "キャンセル",
"prefs_users_dialog_button_save": "保存", "common_save": "保存",
"prefs_users_table_user_header": "ユーザー名", "prefs_users_table_user_header": "ユーザー名",
"prefs_users_dialog_title_add": "ユーザー追加", "prefs_users_dialog_title_add": "ユーザー追加",
"prefs_users_dialog_title_edit": "ユーザー編集", "prefs_users_dialog_title_edit": "ユーザー編集",

View File

@@ -126,10 +126,10 @@
"prefs_users_dialog_title_add": "사용자 추가", "prefs_users_dialog_title_add": "사용자 추가",
"prefs_users_dialog_title_edit": "사용자 편집", "prefs_users_dialog_title_edit": "사용자 편집",
"prefs_users_dialog_base_url_label": "서비스 URL, 예를 들면 https://ntfy.sh", "prefs_users_dialog_base_url_label": "서비스 URL, 예를 들면 https://ntfy.sh",
"prefs_users_dialog_button_cancel": "취소", "common_cancel": "취소",
"prefs_users_dialog_button_save": "저장", "common_save": "저장",
"prefs_appearance_title": "표시 설정", "prefs_appearance_title": "표시 설정",
"prefs_users_dialog_button_add": "추가", "common_add": "추가",
"prefs_appearance_language_title": "언어", "prefs_appearance_language_title": "언어",
"priority_min": "최하", "priority_min": "최하",
"priority_low": "낮음", "priority_low": "낮음",

View File

@@ -90,7 +90,7 @@
"prefs_users_dialog_title_edit": "Rediger bruker", "prefs_users_dialog_title_edit": "Rediger bruker",
"prefs_users_dialog_base_url_label": "Tjeneste-nettadresse, f.eks. https://ntfy.sh", "prefs_users_dialog_base_url_label": "Tjeneste-nettadresse, f.eks. https://ntfy.sh",
"prefs_users_dialog_password_label": "Passord", "prefs_users_dialog_password_label": "Passord",
"prefs_users_dialog_button_save": "Lagre", "common_save": "Lagre",
"prefs_appearance_title": "Utseende", "prefs_appearance_title": "Utseende",
"prefs_appearance_language_title": "Språk", "prefs_appearance_language_title": "Språk",
"prefs_users_dialog_username_label": "Brukernavn, f.eks. phil", "prefs_users_dialog_username_label": "Brukernavn, f.eks. phil",
@@ -102,7 +102,7 @@
"publish_dialog_topic_label": "Emnenavn", "publish_dialog_topic_label": "Emnenavn",
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag", "prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
"notifications_click_copy_url_button": "Kopier lenke", "notifications_click_copy_url_button": "Kopier lenke",
"error_boundary_title": "Oida. Ntfy krasjet.", "error_boundary_title": "Oida, ntfy krasjet",
"publish_dialog_message_placeholder": "Skriv en melding her", "publish_dialog_message_placeholder": "Skriv en melding her",
"publish_dialog_button_cancel": "Avbryt", "publish_dialog_button_cancel": "Avbryt",
"prefs_notifications_min_priority_title": "Minimumsprioritet", "prefs_notifications_min_priority_title": "Minimumsprioritet",
@@ -116,11 +116,76 @@
"subscribe_dialog_login_button_back": "Tilbake", "subscribe_dialog_login_button_back": "Tilbake",
"prefs_notifications_delete_after_three_hours": "Etter tre timer", "prefs_notifications_delete_after_three_hours": "Etter tre timer",
"prefs_users_table_base_url_header": "Tjeneste-nettadresse", "prefs_users_table_base_url_header": "Tjeneste-nettadresse",
"prefs_users_dialog_button_cancel": "Avbryt", "common_cancel": "Avbryt",
"prefs_users_dialog_button_add": "Legg til", "common_add": "Legg til",
"publish_dialog_chip_attach_url_label": "Legg ved fil per nettadresse", "publish_dialog_chip_attach_url_label": "Legg til fil med nettadresse",
"publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi", "publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi",
"prefs_notifications_sound_description_none": "Merknader er lydløse når de mottas", "prefs_notifications_sound_description_none": "Merknader spiller ikke lyd når de mottas",
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler", "subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler",
"prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere" "prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere",
"notifications_no_subscriptions_title": "Det ser ut til at du ikke har noen abonnementer ennå.",
"publish_dialog_attachment_limits_file_and_quota_reached": "overskrider {{fileSizeLimit}} filgrense og kvote, {{remainingBytes}} gjenstår",
"publish_dialog_attachment_limits_file_reached": "overskrider filgrensen på {{fileSizeLimit}}",
"publish_dialog_title_label": "Tittel",
"publish_dialog_title_placeholder": "Varslingstittel, f.eks. Diskplassvarsel",
"publish_dialog_topic_placeholder": "Emnenavn, f.eks. halgeir_varsler",
"publish_dialog_chip_click_label": "Klikk URL",
"publish_dialog_chip_delay_label": "Forsink leveringen",
"publish_dialog_details_examples_description": "For eksempler og en detaljert beskrivelse av alle sendefunksjoner, se <docsLink>dokumentasjonen</docsLink>.",
"publish_dialog_base_url_placeholder": "Tjeneste-URL, f.eks. https://example.com",
"alert_grant_description": "Gi nettleseren din tillatelse til å vise skrivebordsvarsler.",
"alert_not_supported_description": "Varsler støttes ikke i nettleseren din.",
"notifications_attachment_file_app": "Android-app-fil",
"notifications_no_subscriptions_description": "Klikk på \"{{linktext}}\"-koblingen for å opprette eller abonnere på et emne. Etter det kan du sende meldinger via PUT eller POST, og du vil motta varsler her.",
"notifications_actions_http_request_title": "Send HTTP {{metode}} til {{url}}",
"notifications_none_for_any_description": "For å sende varsler til et emne, bare PUT eller POST til emne-URLen. Her er et eksempel som bruker et av emnene dine.",
"notifications_more_details": "For mer informasjon, sjekk ut <websiteLink>nettstedet</websiteLink> eller <docsLink>dokumentasjonen</docsLink>.",
"publish_dialog_attachment_limits_quota_reached": "overskrider kvoten, {{remainingBytes}} gjenstår",
"publish_dialog_click_reset": "Fjern klikk-URL",
"publish_dialog_delay_placeholder": "Forsinket levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (bare på engelsk)",
"emoji_picker_search_clear": "Tøm søk",
"subscribe_dialog_subscribe_description": "Det kan hende emner ikke er passordsbeskyttet, så velg et navn som ikke er enkelt å gjette. Når du har abonnert kan du utføre PUT/POST av merknader.",
"publish_dialog_checkbox_publish_another": "Publiser enda en",
"subscribe_dialog_login_description": "Dette emnet er passordbeskyttet. Vennligst skriv inn brukernavn og passord for å abonnere.",
"prefs_notifications_sound_play": "Spill av valgt lyd",
"subscribe_dialog_error_user_not_authorized": "Bruker {{brukernavn}} ikke autorisert",
"prefs_users_delete_button": "Slett bruker",
"error_boundary_unsupported_indexeddb_description": "ntfy-nettappen trenger IndexedDB for å fungere, og nettleseren din støtter ikke IndexedDB i privat nettlesingsmodus.<br/><br/>Selv om dette er uheldig, gir det heller ikke så mye mening å bruke ntfy-nettappen i privat surfemodus uansett, fordi alt er lagret i nettleserlagringen. Du kan lese mer om det <githubLink>i denne GitHub-feilmeldingen</githubLink>, eller snakk med oss på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
"action_bar_show_menu": "Vis meny",
"action_bar_toggle_mute": "Aktiver/deaktiver notifikasjoner",
"prefs_notifications_min_priority_description_max": "Vis merknader hvis prioritet er 5 (maks.)",
"prefs_notifications_min_priority_any": "Hvilken som helst prioritet",
"prefs_notifications_min_priority_low_and_higher": "Lav prioritet og høyere",
"prefs_users_description": "Legg til/fjern brukere for dine beskyttede emner her. Vær oppmerksom på at brukernavn og passord er lagret i nettleserens lokale lagring.",
"error_boundary_description": "Dette skal åpenbart ikke skje. Beklager dette.<br/>Hvis du har et minutt, vennligst <githubLink>rapporter dette på GitHub</githubLink>, eller gi oss beskjed via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
"action_bar_logo_alt": "ntfy logo",
"message_bar_publish": "Publiser melding",
"action_bar_toggle_action_menu": "Åpne/lukk handlingsmeny",
"message_bar_show_dialog": "Vis publiseringsdialog",
"nav_button_muted": "Varsler dempet",
"nav_button_connecting": "kobler til",
"notifications_list": "Varslingsliste",
"notifications_list_item": "Varsling",
"notifications_mark_read": "Merk som lest",
"notifications_delete": "Slett",
"notifications_priority_x": "Prioritet {{prioritet}}",
"notifications_new_indicator": "Nytt varsel",
"notifications_attachment_image": "Vedlagt bilde",
"notifications_attachment_file_image": "bildefil",
"notifications_attachment_file_video": "videofil",
"notifications_attachment_file_audio": "lydfil",
"notifications_attachment_file_document": "annet dokument",
"notifications_actions_not_supported": "Handling støttes ikke i nettappen",
"notifications_none_for_topic_description": "For å sende varsler til dette emnet, bare PUT eller POST til emne-URLen.",
"publish_dialog_emoji_picker_show": "Velg emoji",
"publish_dialog_topic_reset": "Tilbakestill emne",
"publish_dialog_click_label": "Klikk URL",
"publish_dialog_email_reset": "Fjern videresending av e-post",
"publish_dialog_attach_reset": "Fjern URL vedlegg",
"publish_dialog_delay_reset": "Fjern forsinket levering",
"publish_dialog_attached_file_remove": "Fjern vedlagt fil",
"subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
"prefs_users_table": "Brukertabell",
"prefs_users_edit_button": "Rediger bruker",
"error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke"
} }

View File

@@ -1,13 +1,13 @@
{ {
"action_bar_settings": "Instellingen", "action_bar_settings": "Instellingen",
"action_bar_send_test_notification": "Stuur test notificatie", "action_bar_send_test_notification": "Verstuur testnotificatie.",
"action_bar_clear_notifications": "Wis alle notificaties", "action_bar_clear_notifications": "Wis alle notificaties",
"message_bar_type_message": "Typ hier een bericht", "message_bar_type_message": "Typ hier een bericht",
"action_bar_unsubscribe": "Afmelden", "action_bar_unsubscribe": "Afmelden",
"message_bar_error_publishing": "Fout bij publiceren notificatie", "message_bar_error_publishing": "Fout bij publiceren notificatie",
"nav_topics_title": "Geabonneerde onderwerpen", "nav_topics_title": "Geabonneerde onderwerpen",
"nav_button_settings": "Instellingen", "nav_button_settings": "Instellingen",
"alert_not_supported_description": "Notificaties worden niet ondersteund in je browser.", "alert_not_supported_description": "Notificaties worden niet ondersteund door je browser.",
"notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.", "notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
"publish_dialog_tags_label": "Tags", "publish_dialog_tags_label": "Tags",
"publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen", "publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
@@ -26,20 +26,20 @@
"action_bar_show_menu": "Toon menu", "action_bar_show_menu": "Toon menu",
"action_bar_logo_alt": "ntfy logo", "action_bar_logo_alt": "ntfy logo",
"action_bar_toggle_mute": "Notificaties dempen/opheffen", "action_bar_toggle_mute": "Notificaties dempen/opheffen",
"action_bar_toggle_action_menu": "Actie menu openen/sluiten", "action_bar_toggle_action_menu": "Open/Sluit actiemenu",
"message_bar_show_dialog": "Toon publicatie venster", "message_bar_show_dialog": "Toon publicatie venster",
"message_bar_publish": "Bericht publiceren", "message_bar_publish": "Bericht publiceren",
"nav_button_all_notifications": "Alle notificaties", "nav_button_all_notifications": "Alle notificaties",
"nav_button_documentation": "Documentatie", "nav_button_documentation": "Documentatie",
"nav_button_publish_message": "Notificatie publiceren", "nav_button_publish_message": "Notificatie publiceren",
"nav_button_subscribe": "Onderwerp abonneren", "nav_button_subscribe": "Abonneer op onderwerp",
"nav_button_muted": "Notificaties gedempt", "nav_button_muted": "Notificaties gedempt",
"nav_button_connecting": "verbinden", "nav_button_connecting": "verbinden",
"alert_grant_title": "Notificaties zijn uitgeschakeld", "alert_grant_title": "Notificaties zijn uitgeschakeld",
"alert_grant_description": "Geef je browser toestemming om meldingen weer te geven.", "alert_grant_description": "Verleen je browser toestemming voor het weergeven van notificaties.",
"alert_grant_button": "Nu toestaan", "alert_grant_button": "Nu toestaan",
"alert_not_supported_title": "Notificaties zijn niet ondersteund", "alert_not_supported_title": "Notificaties zijn niet ondersteund",
"notifications_list": "Notificaties lijst", "notifications_list": "Notificatielijst",
"notifications_list_item": "Notificatie", "notifications_list_item": "Notificatie",
"notifications_mark_read": "Markeer als gelezen", "notifications_mark_read": "Markeer als gelezen",
"notifications_delete": "Verwijder", "notifications_delete": "Verwijder",
@@ -59,7 +59,7 @@
"notifications_attachment_file_audio": "audiobestand", "notifications_attachment_file_audio": "audiobestand",
"notifications_attachment_file_app": "Android app bestand", "notifications_attachment_file_app": "Android app bestand",
"notifications_attachment_file_document": "overig document", "notifications_attachment_file_document": "overig document",
"notifications_click_copy_url_title": "URL naar klembord kopiëren", "notifications_click_copy_url_title": "link URL naar klembord kopiëren",
"notifications_click_copy_url_button": "Link kopiëren", "notifications_click_copy_url_button": "Link kopiëren",
"notifications_click_open_button": "Link openen", "notifications_click_open_button": "Link openen",
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.", "notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.",
@@ -73,7 +73,7 @@
"publish_dialog_title_no_topic": "Notificatie publiceren", "publish_dialog_title_no_topic": "Notificatie publiceren",
"publish_dialog_progress_uploading": "Uploaden …", "publish_dialog_progress_uploading": "Uploaden …",
"notifications_actions_open_url_title": "Ga naar {{url}}", "notifications_actions_open_url_title": "Ga naar {{url}}",
"notifications_actions_not_supported": "Deze actie is niet ondersteund in de web applicatie", "notifications_actions_not_supported": "Actie wordt niet ondersteund in de webapplicatie",
"notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}", "notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
"notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.", "notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
"publish_dialog_priority_low": "Lage prioriteit", "publish_dialog_priority_low": "Lage prioriteit",
@@ -177,9 +177,9 @@
"prefs_users_table_base_url_header": "Service URL", "prefs_users_table_base_url_header": "Service URL",
"prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh", "prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh",
"prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil", "prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil",
"prefs_users_dialog_button_cancel": "Annuleren", "common_cancel": "Annuleren",
"prefs_users_dialog_button_add": "Toevoegen", "common_add": "Toevoegen",
"prefs_users_dialog_button_save": "Bewaren", "common_save": "Bewaren",
"prefs_appearance_title": "Weergave", "prefs_appearance_title": "Weergave",
"prefs_appearance_language_title": "Taal", "prefs_appearance_language_title": "Taal",
"priority_min": "min", "priority_min": "min",

View File

@@ -141,9 +141,9 @@
"prefs_users_delete_button": "Usuń użytkownika", "prefs_users_delete_button": "Usuń użytkownika",
"prefs_users_table_base_url_header": "Adres URL usługi", "prefs_users_table_base_url_header": "Adres URL usługi",
"prefs_users_dialog_title_add": "Dodaj użytkownika", "prefs_users_dialog_title_add": "Dodaj użytkownika",
"prefs_users_dialog_button_cancel": "Anuluj", "common_cancel": "Anuluj",
"prefs_users_dialog_button_add": "Dodaj", "common_add": "Dodaj",
"prefs_users_dialog_button_save": "Zapisz", "common_save": "Zapisz",
"prefs_appearance_title": "Wygląd", "prefs_appearance_title": "Wygląd",
"prefs_appearance_language_title": "Język", "prefs_appearance_language_title": "Język",
"error_boundary_title": "Oh nie, ntfy przestało działać", "error_boundary_title": "Oh nie, ntfy przestało działać",

View File

@@ -1 +1,191 @@
{} {
"action_bar_clear_notifications": "Limpar todas as notificações",
"action_bar_send_test_notification": "Enviar notificação de teste",
"action_bar_unsubscribe": "Anular subscrição",
"action_bar_toggle_mute": "Ativa/Desativa notificações",
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
"message_bar_type_message": "Escreva uma mensagem aqui",
"message_bar_error_publishing": "Erro ao publicar notificação",
"message_bar_publish": "Publicar mensagem",
"nav_topics_title": "Tópicos subscritos",
"nav_button_all_notifications": "Todas notificações",
"nav_button_settings": "Configurações",
"nav_button_documentation": "Documentação",
"nav_button_publish_message": "Publicar notificação",
"nav_button_subscribe": "Subscrever tópico",
"nav_button_muted": "Notificações desativadas",
"nav_button_connecting": "A ligar",
"alert_grant_title": "As notificações estão desativadas",
"alert_grant_description": "Conceder permissão ao seu navegador para mostrar notificações.",
"alert_not_supported_title": "Notificações não suportadas",
"notifications_list": "Lista de notificações",
"alert_not_supported_description": "As notificações não são suportadas pelo seu navegador.",
"notifications_list_item": "Notificação",
"notifications_mark_read": "Marcar como lido",
"notifications_delete": "Apagar",
"notifications_copied_to_clipboard": "Copiado para a área de transferência",
"notifications_tags": "Etiquetas",
"notifications_priority_x": "Prioridade {{priority}}",
"notifications_new_indicator": "Nova notificação",
"notifications_attachment_image": "Imagem anexada",
"notifications_attachment_copy_url_title": "Copiar URL do anexo para a área de transferência",
"notifications_attachment_copy_url_button": "Copiar URL",
"notifications_attachment_open_title": "Ir para {{url}}",
"notifications_attachment_link_expired": "a ligação de transferência expirou",
"notifications_attachment_open_button": "Abrir anexo",
"notifications_attachment_link_expires": "a ligação expira em {{date}}",
"notifications_attachment_file_image": "ficheiro de imagem",
"notifications_attachment_file_video": "ficheiro de vídeo",
"notifications_attachment_file_audio": "ficheiro de áudio",
"notifications_attachment_file_app": "ficheiro apk Android",
"notifications_attachment_file_document": "outros documentos",
"notifications_click_copy_url_title": "Copiar URL da ligação para a área de transferência",
"notifications_click_copy_url_button": "Copiar ligação",
"notifications_click_open_button": "Abrir ligação",
"notifications_actions_open_url_title": "Ir para {{url}}",
"notifications_actions_not_supported": "Ação não suportada na app web",
"notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}",
"notifications_none_for_topic_title": "Ainda não recebeu nenhuma notificação deste tópico.",
"notifications_none_for_topic_description": "Para enviar notificações deste tópico, basta usar os métodos PUT ou POST no URL do tópico.",
"notifications_none_for_any_title": "Ainda não recebeu nenhuma notificação.",
"notifications_none_for_any_description": "Para enviar notificações dum tópico, basta usar os métodos PUT ou POST no URL do tópico. Eis um exemplo usando um dos seus tópicos.",
"notifications_no_subscriptions_title": "Parece que ainda não tem nenhuma inscrição.",
"notifications_no_subscriptions_description": "Clique na ligação \"{{linktext}}\" para criar ou subscrever um tópico. Depois, poderá enviar mensagens via PUT ou POST e receberá notificações aqui.",
"notifications_example": "Exemplo",
"notifications_more_details": "Para mais informações, aceda ao <websiteLink>site</websiteLink> ou à <docsLink>documentação</docsLink>.",
"notifications_loading": "A carregar notificações…",
"publish_dialog_title_topic": "Publicar em {{topic}}",
"publish_dialog_title_no_topic": "Publicar notificação",
"publish_dialog_progress_uploading": "A enviar …",
"publish_dialog_progress_uploading_detail": "A enviar {{loaded}}/{{total}} ({{percent}}%)…",
"publish_dialog_message_published": "Notificação publicada",
"publish_dialog_attachment_limits_file_and_quota_reached": "excede limite de ficheiro de {{fileSizeLimit}} e cota, {{remainingBytes}} restante(s)",
"publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restante(s)",
"publish_dialog_priority_min": "Prioridade mínima",
"publish_dialog_priority_low": "Prioridade baixa",
"publish_dialog_priority_default": "Prioridade padrão",
"publish_dialog_priority_high": "Prioridade alta",
"publish_dialog_base_url_label": "URL de serviço",
"publish_dialog_base_url_placeholder": "URL de serviço, por exemplo: https://exemplo.com",
"publish_dialog_topic_label": "Nome do tópico",
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo: \"avisos_do_filipe\"",
"publish_dialog_topic_reset": "Limpar tópico",
"publish_dialog_title_placeholder": "Título da notificação, por exemplo: \"Alerta de espaço em disco\"",
"publish_dialog_message_label": "Mensagem",
"publish_dialog_message_placeholder": "Escreva uma mensagem aqui",
"publish_dialog_tags_label": "Etiquetas",
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup",
"publish_dialog_priority_label": "Prioridade",
"publish_dialog_click_label": "URL de clique",
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
"publish_dialog_click_reset": "Remover URL de clique",
"publish_dialog_email_label": "Email",
"publish_dialog_filename_placeholder": "Nome do ficheiro anexado",
"publish_dialog_email_placeholder": "Endereça para o qual encaminhar a notificação, por exemplo: filipe@exemplo.com",
"publish_dialog_email_reset": "Remover encaminhamento por email",
"publish_dialog_attach_label": "URL de anexo",
"publish_dialog_attach_placeholder": "Anexar ficheiro por URL, por exemplo: https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Remover URL de anexo",
"publish_dialog_filename_label": "Nome do ficheiro",
"publish_dialog_delay_label": "Atraso",
"publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo \"{{{unixTimestamp}}\", \"{{relativeTime}}\", ou \"{{naturalLanguage}}\" (apenas em Inglês)",
"publish_dialog_other_features": "Outras funcionalidades:",
"publish_dialog_chip_click_label": "URL de clique",
"publish_dialog_chip_topic_label": "Alterar tópico",
"publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todas as funcionalidades de envio, consulte a <docsLink>documentação</docsLink>.",
"publish_dialog_button_cancel_sending": "Cancelar o envio",
"publish_dialog_attached_file_filename_placeholder": "Nome do ficheiro anexado",
"publish_dialog_attached_file_remove": "Remover ficheiro anexado",
"emoji_picker_search_clear": "Limpar pesquisa",
"subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por palavra-passe, por isso escolha um nome que não seja fácil de adivinhar. Uma vez subscrito, pode usar os métodos PUT/POST para publicar notificações.",
"subscribe_dialog_subscribe_use_another_label": "Usar outro servidor",
"subscribe_dialog_error_user_not_authorized": "Utilizador {{username}} não autorizado",
"prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)",
"prefs_notifications_delete_after_one_week": "Após uma semana",
"prefs_notifications_delete_after_one_month": "Após um mês",
"prefs_notifications_delete_after_never_description": "As notificações nunca serão eliminadas automaticamente",
"prefs_notifications_delete_after_one_week_description": "As notificações serão eliminadas automaticamente após uma semana",
"prefs_notifications_delete_after_one_month_description": "As notificações serão eliminadas automaticamente após um mês",
"prefs_users_dialog_username_label": "Utilizador, por exemplo: \"filipe\"",
"prefs_users_dialog_password_label": "Palavra-passe",
"common_cancel": "Cancelar",
"common_add": "Adicionar",
"error_boundary_description": "Obviamente, isto não devia acontecer, lamentamos o sucedido.<br/>Se tiver um minuto, por favor <githubLink>relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Erro (\"stack trace\")",
"error_boundary_gathering_info": "A recolher mais informações …",
"error_boundary_unsupported_indexeddb_title": "Navegação anónima não suportada",
"error_boundary_unsupported_indexeddb_description": "A aplicação web ntfy necessita da \"IndexedDB\" para funcionar e o seu navegador não a suporta no modo de navegação privada.<br/><br/>Embora isso seja inconveniente, também não faz muito sentido usar a aplicação no modo de navegação privada de qualquer maneira, visto que tudo é guardado no armazenamento do navegador. Pode ler mais sobre isso <githubLink>nesta questão no GitHub</githubLink>, ou falar connosco por <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"action_bar_show_menu": "Mostrar menu",
"action_bar_logo_alt": "logótipo do ntfy",
"action_bar_settings": "Configurações",
"message_bar_show_dialog": "Mostrar caixa de publicação",
"alert_grant_button": "Conceder agora",
"publish_dialog_attachment_limits_file_reached": "excede o limite de ficheiro de {{fileSizeLimit}}",
"publish_dialog_emoji_picker_show": "Escolher emoji",
"publish_dialog_priority_max": "Prioridade máxima",
"publish_dialog_title_label": "Título",
"publish_dialog_delay_reset": "Remover atraso de entrega",
"publish_dialog_chip_email_label": "Encaminhar para email",
"publish_dialog_chip_attach_url_label": "Anexar ficheiro por URL",
"publish_dialog_chip_attach_file_label": "Anexar ficheiro local",
"publish_dialog_chip_delay_label": "Atraso de entrega",
"publish_dialog_button_cancel": "Cancelar",
"publish_dialog_button_send": "Enviar",
"publish_dialog_checkbox_publish_another": "Publicar outra",
"publish_dialog_attached_file_title": "Ficheiro anexado:",
"publish_dialog_drop_file_here": "Arraste o ficheiro para aqui",
"emoji_picker_search_placeholder": "Pesquisar emoji",
"subscribe_dialog_subscribe_title": "Subscrever tópico",
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo: \"alertas_do_filipe\"",
"subscribe_dialog_subscribe_base_url_label": "URL de serviço",
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
"subscribe_dialog_subscribe_button_subscribe": "Subscrever",
"subscribe_dialog_login_title": "Autenticação necessária",
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
"subscribe_dialog_login_password_label": "Palavra-passe",
"subscribe_dialog_login_button_back": "Voltar",
"subscribe_dialog_login_button_login": "Autenticar",
"subscribe_dialog_error_user_anonymous": "anónimo",
"prefs_notifications_title": "Notificações",
"prefs_notifications_sound_title": "Som de notificações",
"prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam",
"prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam",
"prefs_notifications_sound_no_sound": "Sem som",
"prefs_notifications_sound_play": "Reproduzir som selecionado",
"prefs_notifications_min_priority_title": "Prioridade mínima",
"prefs_notifications_min_priority_description_any": "A mostrar todas as notificações, independentemente da prioridade",
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima",
"prefs_notifications_min_priority_any": "Qualquer prioridade",
"prefs_notifications_min_priority_low_and_higher": "Prioridade baixa e acima",
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
"prefs_notifications_min_priority_high_and_higher": "Prioridade alta e acima",
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
"prefs_notifications_delete_after_title": "Eliminar notificações",
"prefs_notifications_delete_after_never": "Nunca",
"prefs_notifications_delete_after_three_hours": "Após três horas",
"prefs_notifications_delete_after_one_day": "Após um dia",
"prefs_notifications_delete_after_three_hours_description": "As notificações serão eliminadas automaticamente após três horas",
"prefs_notifications_delete_after_one_day_description": "As notificações serão eliminadas automaticamente após um dia",
"prefs_users_title": "Gerir utilizadores",
"prefs_users_description": "Adicionar/remover utilizadores aos seus tópicos protegidos. Note que o utilizador e palavra-passe são guardados no armazenamento local do navegador.",
"prefs_users_table": "Tabela de utilizadores",
"prefs_users_add_button": "Adicionar utilizador",
"prefs_users_edit_button": "Editar utilizador",
"prefs_users_delete_button": "Apagar utilizador",
"prefs_users_table_user_header": "Utilizador",
"prefs_users_table_base_url_header": "URL de serviço",
"prefs_users_dialog_title_add": "Adicionar utilizador",
"prefs_users_dialog_title_edit": "Editar utilizador",
"prefs_users_dialog_base_url_label": "URL de serviço, por exemplo: https://ntfy.sh",
"common_save": "Gravar",
"prefs_appearance_title": "Aparência",
"prefs_appearance_language_title": "Idioma",
"priority_min": "mínima",
"priority_low": "baixa",
"priority_default": "padrão",
"priority_high": "alta",
"priority_max": "máxima",
"error_boundary_title": "Oh não, o ntfy parou de funcionar",
"error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")"
}

Some files were not shown because too many files have changed in this diff Show More