Compare commits

...

183 Commits

Author SHA1 Message Date
Philipp Heckel
d3cfa3456c Install docs 2022-09-27 12:50:07 -04:00
Philipp Heckel
903ba8df4d Bump versions 2022-09-27 12:49:20 -04:00
Philipp Heckel
46fcbdb827 Deprecation warnings 2022-09-27 12:45:43 -04:00
Philipp Heckel
419bfecd6f Reformatting, make update 2022-09-27 12:37:02 -04:00
Philipp Heckel
a9019131cf Polish 2022-09-27 07:44:00 -04:00
Philipp Heckel
5e0e8e7db0 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2022-09-27 07:37:08 -04:00
YJSoft
f0f4de2719 Added translation using Weblate (Korean) 2022-09-27 10:37:39 +02:00
Patryk
61d5293ba0 Translated using Weblate (Polish)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pl/
2022-09-24 00:15:31 +02:00
Philipp Heckel
fd21d2f4ce Added Ukranian 2022-09-23 12:55:40 -04:00
Philipp Heckel
e6b07e22a8 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2022-09-23 12:43:19 -04:00
Philipp Heckel
b117c217e4 Deps 2022-09-23 12:42:44 -04:00
Patryk
1e823b4f89 Added translation using Weblate (Polish) 2022-09-22 22:14:30 +02:00
Philipp C. Heckel
36e805828e Merge pull request #403 from the-lazy-fox/patch-1
Update develop.md
2022-09-20 14:18:43 -04:00
TheLazyFox
b37b3d97ad Update develop.md 2022-09-19 13:59:29 +02:00
Philipp Heckel
4446795dad Integrations 2022-09-12 23:31:30 -04:00
Philipp Heckel
ed4cc86c5c Add whitelisting logic for nginx to docs 2022-09-12 14:17:33 -04:00
Philipp Heckel
6476978a2e Move things 2022-09-11 16:31:39 -04:00
Philipp Heckel
23a127d20b Docs 2022-09-11 16:25:40 -04:00
Philipp Heckel
ae1fb74ac6 Merge branch 'main' of github.com:binwiederhier/ntfy into icons 2022-09-10 23:22:48 -04:00
Philipp Heckel
38c3b1fbf7 Release notes 2022-09-10 23:19:35 -04:00
Vladimir Kopitsa
42c0dbab65 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2022-09-09 16:17:50 +02:00
Vladimir Kopitsa
97a55babe1 Added translation using Weblate (Ukrainian) 2022-09-08 15:07:29 +02:00
Philipp Heckel
c84d10a6df Bump releases page 2022-09-05 15:17:00 -04:00
Philipp Heckel
f7f72f44a1 Merge branch 'main' into la-ninpre/main 2022-09-05 15:14:14 -04:00
Philipp Heckel
f54dce4c3f Bump versions 2022-09-05 15:12:04 -04:00
Philipp Heckel
cee46044cd Donation FAQ 2022-09-05 15:05:44 -04:00
Philipp C. Heckel
58e782b475 Create FUNDING.yml 2022-09-05 14:29:38 -04:00
Philipp Heckel
601f01bc49 UptimeRobot docs, release notes 2022-09-03 16:01:28 -04:00
Philipp Heckel
9dc19f1d07 Merge branch 'add-UptimeRobot-example' into main 2022-09-03 15:45:59 -04:00
Philipp Heckel
4ea1e23361 Docker install docs 2022-09-03 15:34:34 -04:00
la-ninpre
2fb93b1eb7 cmd: add go1.18 build directives 2022-09-01 00:49:08 +03:00
Philipp C. Heckel
eed3e28790 Merge pull request #392 from connorlanigan/patch-1
docs: Mismatched quotation mark
2022-08-31 16:48:02 -04:00
la-ninpre
e60e770419 cmd: unify unix-specific code 2022-08-31 23:26:43 +03:00
Connor Lanigan
62c8cafff9 docs: Mismatched quotation mark 2022-08-31 22:19:37 +02:00
joephein
5181acdd7c Stylistic improvement 2022-08-31 08:48:42 +02:00
joephein
6db2908d69 Fixed one more spelling issue in the new UptimeRobot example 2022-08-31 08:47:22 +02:00
joephein
925017f040 Added UptimeRobot example 2022-08-31 08:43:24 +02:00
Philipp Heckel
6935d83ab3 Links 2022-08-28 21:51:56 -04:00
Philipp C. Heckel
54f762558a Delete FUNDING.yml 2022-08-27 08:33:13 -04:00
Philipp C. Heckel
a22fd4db1c Create FUNDING.yml 2022-08-27 07:22:27 -04:00
Philipp C. Heckel
3f85e0a0c8 Update README.md 2022-08-21 21:41:03 -04:00
Philipp Heckel
b0d58a618e Fix test 2022-08-21 21:32:53 -04:00
Philipp C. Heckel
29a248701f Merge pull request #384 from christophehenry/document-pushkey-error
Document Matrix pushkey error + set log level to warnings for Matrix errors
2022-08-21 12:37:31 -04:00
Christophe Henry
ec64b412a8 Document Matrix pushkey error + set log level to warnings for Matrix errors 2022-08-21 17:03:56 +02:00
Philipp Heckel
f5f9758a50 Merge branch 'integrations-page' into main 2022-08-21 11:00:07 -04:00
Philipp Heckel
0d5362f0e4 Bump versions 2022-08-21 11:00:01 -04:00
Philipp Heckel
fb7a2455fa More projects 2022-08-21 10:58:20 -04:00
Philipp Heckel
85b2a674ae WIP: Integrations page with links to projects 2022-08-20 22:22:18 -04:00
Philipp Heckel
4277d6e4a6 Remove unnecessary else branch 2022-08-18 21:04:30 -04:00
Philipp Heckel
3aa0eb7d1d Release notes 2022-08-18 20:32:51 -04:00
Philipp Heckel
ec3e6e902e Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-08-18 20:25:55 -04:00
Philipp C. Heckel
9d0231ea07 Merge pull request #372 from wunter8/default-user-password-command
Client: default-user, default-password, default-command
2022-08-18 20:25:46 -04:00
Philipp Heckel
08d717afbf Bump deps 2022-08-18 20:22:48 -04:00
Philipp C. Heckel
9e151253e3 Merge pull request #381 from michalsrutek/patch-1
Fix CLI address in README
2022-08-14 08:32:08 -04:00
Michal Šrůtek
e4c760f1de Fix CLI address in README 2022-08-14 14:29:24 +02:00
Philipp C. Heckel
4c566c9f31 Merge pull request #373 from cyqsimon/commit-var
Move `COMMIT` into a variable so it could be overridden if desired
2022-08-03 13:33:10 -04:00
cyqsimon
a498e43d61 Move COMMIT into a variable so it could be overridden if desired 2022-08-02 03:40:33 +08:00
Hunter Kehoe
613d5d554f add example config for default-user, default-password, default-command 2022-07-31 16:46:56 -06:00
Hunter Kehoe
f6a42e7dcd add docs explaining default-user, default-password, default-command 2022-07-31 16:40:07 -06:00
Hunter Kehoe
8956837443 add default-user, default-password, and default-command options to client.yml config 2022-07-31 13:12:38 -06:00
Philipp C. Heckel
28975e9433 Merge pull request #368 from binwiederhier/dependabot/npm_and_yarn/web/terser-5.14.2
Bump terser from 5.14.1 to 5.14.2 in /web
2022-07-24 00:47:01 -04:00
poi
206beb31c4 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2022-07-22 13:18:53 +02:00
dependabot[bot]
38e61d6a99 Bump terser from 5.14.1 to 5.14.2 in /web
Bumps [terser](https://github.com/terser/terser) from 5.14.1 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-21 06:01:20 +00:00
Hunter Kehoe
3c5a10de17 combine attachment and icon url regex 2022-07-17 15:47:21 -06:00
Hunter Kehoe
99886d7f66 change icon from object to string 2022-07-17 15:40:24 -06:00
Hunter Kehoe
04f2535e92 linting 2022-07-16 14:22:23 -06:00
Hunter Kehoe
d519fd999b notification icons 2022-07-16 14:13:46 -06:00
Philipp C. Heckel
cbcd0e3f0d Merge pull request #362 from elvstejd/main
Fix small typo in spanish translation
2022-07-13 08:36:07 -04:00
Elvis Tejeda
9bcec02f8c Fix typo 2022-07-12 21:35:12 -04:00
Philipp Heckel
88a77cb132 Fix race 2022-07-08 10:16:23 -04:00
Philipp Heckel
10a9aca2a1 Delete expired attachments based on mod time instead of DB entry to avoid races 2022-07-08 10:00:04 -04:00
Philipp Heckel
3e53d8a2c7 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-07-08 08:16:22 -04:00
Philipp Heckel
d8ce68b2cb Switched Pop and Pop Swoosh sounds, closes #352 2022-07-04 14:36:37 -04:00
Philipp Heckel
dd6af3b8f2 Changelog 2022-07-04 14:33:24 -04:00
Philipp Heckel
e874f3516e Docs 2022-07-03 19:36:58 -04:00
Philipp Heckel
bf8077626e Permissions of unix socket 2022-07-03 19:33:01 -04:00
Koro
8532b5b7ea Update documentation. 2022-07-03 17:39:17 -04:00
Koro
ed1673beed Set socket mode after creation. 2022-07-03 17:39:08 -04:00
Koro
89316487e3 Add socket mode command-line option. 2022-07-03 17:22:45 -04:00
Koro
9f358d4793 Add socket mode to configuration struct. 2022-07-03 15:39:19 -04:00
Philipp Heckel
e8953aea3b Fix test, changelog 2022-07-01 09:37:20 -04:00
Philipp Heckel
95bd876be2 Fix HTTP Spec priority header parsing 2022-07-01 09:28:42 -04:00
Philipp C. Heckel
bd6f3ca2e8 Merge pull request #348 from binwiederhier/display-name-web
WIP: DIsplay name for the web app
2022-06-29 19:35:23 -04:00
Philipp Heckel
aee4074792 changelog 2022-06-29 19:35:09 -04:00
Philipp Heckel
4d6c147f24 WIP: DIsplay name for the web app 2022-06-29 15:57:56 -04:00
brianchul
691a77370e Translated using Weblate (Chinese (Traditional))
Currently translated at 28.5% (54 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/
2022-06-29 09:20:41 +02:00
brianchul
d09afd8b60 Added translation using Weblate (Chinese (Traditional)) 2022-06-28 08:06:49 +02:00
Philipp Heckel
2d26a990a9 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-06-27 12:21:26 -04:00
Philipp Heckel
f134bc6dcd Fix PowerShell rendering, changelog 2022-06-27 12:21:11 -04:00
Philipp C. Heckel
50a830c360 Merge pull request #345 from noahpeltier/fix-powershell-docs
Updated syntax on PowerShell examples in docs
2022-06-27 12:13:07 -04:00
=
ae3715222f Updated powershell docs to correct syntax, fixed my goofy typos 2022-06-26 23:46:00 -05:00
=
eb841604c7 Updated powershell examples to correct syntax 2022-06-26 23:39:56 -05:00
Philipp Heckel
30c8d6b02b Fix auth_file not working for ntfy user command 2022-06-24 19:13:10 -04:00
Elisey Kravchuk
b840d7d5f4 Translated using Weblate (Russian)
Currently translated at 82.0% (155 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2022-06-24 19:17:34 +02:00
Philipp Heckel
20f835df50 Changelog 2022-06-24 12:40:27 -04:00
Philipp Heckel
bac5e1fa84 Changelog 2022-06-23 16:46:08 -04:00
Philipp Heckel
69d6cdd786 Bump again 2022-06-23 15:12:13 -04:00
Philipp Heckel
5f2ce5e542 Fix intermittent test failure; bump version 2022-06-23 15:01:35 -04:00
Philipp Heckel
6acb921098 tidy 2022-06-23 14:53:42 -04:00
Philipp Heckel
acf6d4370f Update deps 2022-06-23 14:46:01 -04:00
Philipp Heckel
297601d0f2 Bump version 2022-06-23 14:33:51 -04:00
Philipp Heckel
113900d3eb Cache startup queries 2022-06-23 11:02:45 -04:00
Philipp Heckel
b4a824aa38 Format actions PR, changelog 2022-06-22 20:23:15 -04:00
Philipp C. Heckel
e8569c6008 Merge pull request #341 from wunter8/custom-intent-broadcast-action
allow custom intent in broadcast action without JSON
2022-06-22 20:21:11 -04:00
Philipp Heckel
b74defef14 Enable WAL mode, add changelog 2022-06-22 20:17:47 -04:00
Hunter Kehoe
ee38d76bc2 allow custom intent in broadcast action without JSON 2022-06-22 15:29:16 -06:00
Philipp Heckel
3334d84861 Fix another race, add test 2022-06-22 15:11:50 -04:00
Philipp Heckel
b1089e21f9 Changelog 2022-06-22 14:56:26 -04:00
Philipp C. Heckel
07b5d9a9df Merge pull request #340 from binwiederhier/shorter-lock
WIP: Shorter lock, for #338
2022-06-22 14:48:07 -04:00
Philipp Heckel
9cee8ab888 Call subscriber funtions in individual goroutines 2022-06-22 13:52:49 -04:00
Philipp Heckel
ed9d99fd57 "Fix" data race 2022-06-22 13:47:54 -04:00
Philipp Heckel
edfc1b78a1 Delayed message lock shorter 2022-06-21 20:07:08 -04:00
Philipp Heckel
c1f7bed8d1 Fix tests, lock topic as short as possible 2022-06-21 19:45:23 -04:00
Philipp Heckel
85f2252a77 WIP: Shorter lock, for #338 2022-06-21 19:07:27 -04:00
Philipp C. Heckel
4e29216b5f Merge pull request #335 from binwiederhier/done
WIP: ntfy publish --pid $PID ...
2022-06-21 13:20:34 -04:00
Philipp Heckel
26fda847ca Docs 2022-06-21 11:43:26 -04:00
Philipp Heckel
a160da3ad9 Tests 2022-06-21 11:18:35 -04:00
Philipp Heckel
0080ea5a20 All --wait-cmd 2022-06-20 23:03:16 -04:00
Philipp Heckel
fec4864771 done command 2022-06-20 21:57:54 -04:00
Philipp Heckel
c40338c146 Merge branch 'main' into done 2022-06-20 20:34:00 -04:00
Philipp Heckel
a7d8e69dfd Refine NTFY_PASSWORD logic 2022-06-20 16:03:39 -04:00
Philipp C. Heckel
5b68915fff Merge pull request #327 from Kenix3/add_user_supports_envvar
Add user now supports reading password from an env var.
2022-06-20 15:40:23 -04:00
Kenix3
f3e5961892 Fixes envvar fetch in ntfy user add for password 2022-06-20 14:21:30 -04:00
Kenix
7de7e0de12 Adds missing colon assignment for username variable in ntfy user add command. 2022-06-20 13:26:13 -04:00
Kenix
727c6268b9 Updating order of variables ntfy user add command. 2022-06-20 13:25:31 -04:00
Kenix
50cd50cfdf Moves password stdin down to the original location. 2022-06-20 13:24:42 -04:00
Kenix
1265e69eee Changes user add to use a NTFY_PASSWORD env var rather than NTFY_USER. 2022-06-20 13:19:54 -04:00
Philipp Heckel
d05211648d Fix since=<id> implementation for multiple topics, closes #336 2022-06-20 12:11:52 -04:00
Philipp Heckel
1226a7b70c ntfy publish --pid $PID ... 2022-06-20 10:56:45 -04:00
Philipp Heckel
30c2a67869 Disallow setting upstream-base-url to the same value as base-url 2022-06-19 21:33:17 -04:00
Philipp Heckel
25a4b29ffc Return HTTP 500 on Matrix discovery GET if base-url not configured; log entire HTTP request when TRACE enabled 2022-06-19 21:25:35 -04:00
Philipp Heckel
e578f01e5b Changelog 2022-06-18 21:04:48 -04:00
Philipp Heckel
16047ede61 Changelog 2022-06-18 20:10:28 -04:00
Philipp Heckel
affc79eab0 Changelog 2022-06-17 21:07:43 -04:00
Philipp Heckel
64590343f5 Docs for #329 2022-06-17 21:05:31 -04:00
Philipp C. Heckel
87cf765dcc Merge pull request #330 from wunter8/329-attachment-url-broadcast-intent
update docs to explain attachment name and URL in broadcast intent
2022-06-17 20:59:37 -04:00
Hunter Kehoe
b332e1aaea update docs to explain attachment name and URL in broadcast intent 2022-06-17 07:19:35 -06:00
Philipp Heckel
eef55c35a8 Fix example images 2022-06-16 15:53:15 -04:00
Philipp Heckel
a2c661cbf6 Version bump 2022-06-16 15:38:21 -04:00
Philipp Heckel
9918f4965d Only use last X-Forwarded-For address as visitor address, closes #328 2022-06-16 15:31:09 -04:00
Philipp C. Heckel
1fae61e78f Merge pull request #326 from binwiederhier/matrix
Matrix gateway
2022-06-16 12:55:41 -04:00
Philipp Heckel
df2362e1a7 Update deps 2022-06-16 12:48:43 -04:00
Philipp Heckel
8a56b82813 500-test 2022-06-16 12:42:19 -04:00
Philipp Heckel
6122cf20aa More tests 2022-06-16 12:37:02 -04:00
Philipp Heckel
18bd3c0e55 Docs and Matrix tests 2022-06-16 11:40:56 -04:00
Philipp Heckel
0ff8e968ca Docs 2022-06-15 20:51:42 -04:00
Philipp Heckel
ebbc2838ba Move error handling to main error handling; move matrix logic to its own file 2022-06-15 20:36:49 -04:00
Philipp Heckel
91375b2e8e Minor refactor, added GET 2022-06-15 16:03:12 -04:00
Philipp Heckel
f1d134dfc2 Merge branch 'main' into matrix 2022-06-15 15:19:53 -04:00
Philipp Heckel
cd536e6018 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-06-15 14:45:09 -04:00
Kenix
3dec7efadb Add user now supports reading password from an env var. 2022-06-15 11:42:22 -04:00
Philipp Heckel
27910772f0 Derpyderp 2022-06-14 20:43:17 -04:00
Mayeul Cantan
632c21298f 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/
2022-06-14 16:14:07 +02:00
Philipp Heckel
e9f3edb76b WIP: Matrix 2022-06-13 22:07:30 -04:00
Philipp Heckel
feef15c485 Web app: Show "notifications not supported" alert on HTTP 2022-06-12 16:38:33 -04:00
Philipp Heckel
cf0f002bfa Add version number to ntfy serve output 2022-06-12 11:54:58 -04:00
Philipp Heckel
eb2262d06e Update FAQ 2022-06-12 11:22:15 -04:00
Philipp Heckel
41096ef1b0 Update base-url docs 2022-06-12 10:47:12 -04:00
Philipp Heckel
3c47797bf3 Fix Docker install instructions 2022-06-12 10:43:42 -04:00
Philipp Heckel
a8c9927eab Changelog 2022-06-11 20:51:27 -04:00
Philipp Heckel
8565dc0ff3 Merge branch 'main' of github.com:binwiederhier/ntfy 2022-06-10 21:34:08 -04:00
Philipp Heckel
2b42cea1a3 Allow HEAD requests for file attachments 2022-06-10 21:33:39 -04:00
Philipp Heckel
d7f7aa909c Changelog 2022-06-07 13:43:54 -04:00
Philipp C. Heckel
e5af7fe8d7 Merge pull request #315 from philippdormann/feature/docs-uptime-kuma-example
docs: uptime kuma example
2022-06-07 13:38:37 -04:00
Philipp Dormann
52fcfdccb2 cropped notification samples 2022-06-06 23:36:05 +02:00
Philipp Dormann
9025e2a082 add app notification examples 2022-06-06 23:32:00 +02:00
Philipp Dormann
4667377649 add basic uptime kuma config sample 2022-06-06 23:31:31 +02:00
Philipp Heckel
f459a08f96 Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-06-06 14:38:59 -04:00
Philipp Heckel
f542afb37f Hack to make sure docs are built with the right Python version 2022-06-06 14:38:28 -04:00
Philipp C. Heckel
4baf6996c5 Update README.md 2022-06-06 11:41:51 -04:00
Philipp C. Heckel
81da9a2756 Update README.md 2022-06-06 09:50:26 -04:00
Philipp Heckel
fa98a16195 Nothing to see here, move along 2022-06-06 09:39:34 -04:00
Philipp Heckel
12b2636155 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-06-06 09:28:16 -04:00
Philipp Heckel
10c89b2e55 Changelog 2022-06-05 19:24:44 -04:00
Philipp Heckel
01d8ea0019 Changelog 2022-06-05 11:21:05 -04:00
Philipp Heckel
c7b790e070 Merge branch 'main' of github.com:binwiederhier/ntfy 2022-06-05 07:44:16 -04:00
Philipp C. Heckel
b5eb3a40f4 Merge pull request #311 from kzshantonu/main
Scoop instructions
2022-06-05 07:28:48 -04:00
Kazi
ffb6de7d97 fix typo 2022-06-04 20:29:21 +02:00
Kazi
3ad5ed571d Scoop instructions 2022-06-04 20:27:26 +02:00
郁飞
ad30c50418 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2022-06-04 09:18:32 +02:00
SchoNie
f59c58b08f 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/
2022-06-04 09:18:31 +02:00
Philipp Heckel
86c132f9cd Revert tzdata change 2022-06-02 21:38:27 -04:00
Philipp Heckel
2d7b986c9c Fix macOS install instructions 2022-05-29 17:06:31 -04:00
89 changed files with 6787 additions and 4143 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [binwiederhier]

View File

@@ -64,9 +64,7 @@ builds:
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [windows]
goarch: [amd64]
hooks:
post:
- upx "{{ .Path }}" # apt install upx
# No "upx" for Windows to hopefully avoid Virus warnings
-
id: ntfy_darwin_all
binary: ntfy

View File

@@ -3,7 +3,5 @@ MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
COPY ntfy /usr/bin
RUN apk add --no-cache tzdata
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

View File

@@ -1,5 +1,6 @@
MAKEFLAGS := --jobs=1
VERSION := $(shell git describe --tag)
COMMIT := $(shell git rev-parse --short HEAD)
.PHONY:
@@ -96,7 +97,18 @@ build-deps-ubuntu:
docs: docs-deps docs-build
docs-build: .PHONY
mkdocs build
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
if which python3.8; then \
echo "python3.8 $(shell which mkdocs) build"; \
python3.8 $(shell which mkdocs) build; \
else \
echo "ERROR: Python version too low. mkdocs-material needs >= 3.8"; \
exit 1; \
fi; \
else \
echo "mkdocs build"; \
mkdocs build; \
fi
docs-deps: .PHONY
pip3 install -r requirements.txt
@@ -158,7 +170,7 @@ cli-linux-server: cli-deps-static-sites
-o dist/ntfy_linux_server/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
cli-darwin-server: cli-deps-static-sites
# This is a target to build the CLI (including the server) manually.
@@ -168,7 +180,7 @@ cli-darwin-server: cli-deps-static-sites
-o dist/ntfy_darwin_server/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
cli-client: cli-deps-static-sites
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
@@ -178,7 +190,7 @@ cli-client: cli-deps-static-sites
-o dist/ntfy_client/ntfy \
-tags noserver \
-ldflags \
"-X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
"-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
@@ -289,16 +301,16 @@ release-checks:
# Installing targets
install-linux-amd64: remove-binary
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
sudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
install-linux-armv6: remove-binary
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
sudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy
install-linux-armv7: remove-binary
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
sudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy
install-linux-arm64: remove-binary
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
sudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy
remove-binary:
sudo rm -f /usr/bin/ntfy

View File

@@ -1,5 +1,13 @@
![ntfy](web/public/static/img/ntfy.png)
---
## 👶 Baby break - My baby girl was born!
Hey folks, my daughter was born on 8/30/22, so I'll be taking some time off from working on ntfy. I'll likely return
to working on features and bugs in a few weeks. I hope you understand. I posted some pictures in [#387](https://github.com/binwiederhier/ntfy/issues/387) 🥰
---
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
[![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest)
[![Go Reference](https://pkg.go.dev/badge/heckel.io/ntfy.svg)](https://pkg.go.dev/heckel.io/ntfy)
@@ -8,14 +16,14 @@
[![codecov](https://codecov.io/gh/binwiederhier/ntfy/branch/main/graph/badge.svg?token=A597KQ463G)](https://codecov.io/gh/binwiederhier/ntfy)
[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w)
[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org)
[![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org)
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
It's also open source (as you can plainly see) if you want to run your own.
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [open source](https://github.com/binwiederhier/ntfy-android) [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
too.
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**. There's also an [open source Android app](https://github.com/binwiederhier/ntfy-android) (see [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)), and an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) (see [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)).
<p>
<img src="web/public/static/img/screenshot-curl.png" height="180">
@@ -53,12 +61,17 @@ Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start im
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
</a>
## Donations
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
appreciated.
## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
Third party libraries and resources:
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI
* [github.com/urfave/cli](https://github.com/urfave/cli) (MIT) is used to drive the CLI
* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds
* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds
* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web

View File

@@ -47,6 +47,7 @@ type Message struct { // TODO combine with server.message
Priority int
Tags []string
Click string
Icon string
Attachment *Attachment
// Additional fields
@@ -163,11 +164,12 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
// The method returns a unique subscriptionID that can be used in Unsubscribe.
//
// Example:
// c := client.New(client.NewConfig())
// subscriptionID := c.Subscribe("mytopic")
// for m := range c.Messages {
// fmt.Printf("New message: %s", m.Message)
// }
//
// c := client.New(client.NewConfig())
// subscriptionID := c.Subscribe("mytopic")
// for m := range c.Messages {
// fmt.Printf("New message: %s", m.Message)
// }
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
c.mu.Lock()
defer c.mu.Unlock()

View File

@@ -5,6 +5,12 @@
#
# default-host: https://ntfy.sh
# Defaults below will be used when a topic does not have its own settings
#
# default-user:
# default-password:
# default-command:
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
# or if you cann "ntfy subscribe --from-config" directly.
#

View File

@@ -12,8 +12,11 @@ const (
// Config is the config struct for a Client
type Config struct {
DefaultHost string `yaml:"default-host"`
Subscribe []struct {
DefaultHost string `yaml:"default-host"`
DefaultUser string `yaml:"default-user"`
DefaultPassword string `yaml:"default-password"`
DefaultCommand string `yaml:"default-command"`
Subscribe []struct {
Topic string `yaml:"topic"`
User string `yaml:"user"`
Password string `yaml:"password"`
@@ -25,8 +28,11 @@ type Config struct {
// NewConfig creates a new Config struct for a Client
func NewConfig() *Config {
return &Config{
DefaultHost: DefaultBaseURL,
Subscribe: nil,
DefaultHost: DefaultBaseURL,
DefaultUser: "",
DefaultPassword: "",
DefaultCommand: "",
Subscribe: nil,
}
}

View File

@@ -12,6 +12,9 @@ func TestConfig_Load(t *testing.T) {
filename := filepath.Join(t.TempDir(), "client.yml")
require.Nil(t, os.WriteFile(filename, []byte(`
default-host: http://localhost
default-user: phil
default-password: mypass
default-command: 'echo "Got the message: $message"'
subscribe:
- topic: no-command-with-auth
user: phil
@@ -22,12 +25,16 @@ subscribe:
command: notify-send -i /usr/share/ntfy/logo.png "Important" "$m"
if:
priority: high,urgent
- topic: defaults
`), 0600))
conf, err := client.LoadConfig(filename)
require.Nil(t, err)
require.Equal(t, "http://localhost", conf.DefaultHost)
require.Equal(t, 3, len(conf.Subscribe))
require.Equal(t, "phil", conf.DefaultUser)
require.Equal(t, "mypass", conf.DefaultPassword)
require.Equal(t, `echo "Got the message: $message"`, conf.DefaultCommand)
require.Equal(t, 4, len(conf.Subscribe))
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
require.Equal(t, "", conf.Subscribe[0].Command)
require.Equal(t, "phil", conf.Subscribe[0].User)
@@ -37,4 +44,5 @@ subscribe:
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
require.Equal(t, `notify-send -i /usr/share/ntfy/logo.png "Important" "$m"`, conf.Subscribe[2].Command)
require.Equal(t, "high,urgent", conf.Subscribe[2].If["priority"])
require.Equal(t, "defaults", conf.Subscribe[3].Topic)
}

View File

@@ -56,6 +56,11 @@ func WithClick(url string) PublishOption {
return WithHeader("X-Click", url)
}
// WithIcon makes the notification use the given URL as its icon
func WithIcon(icon string) PublishOption {
return WithHeader("X-Icon", icon)
}
// WithActions adds custom user actions to the notification. The value can be either a JSON array or the
// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details.
func WithActions(value string) PublishOption {

View File

@@ -5,11 +5,14 @@ import (
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func init() {
@@ -20,31 +23,37 @@ var flagsPublish = append(
flagsDefault,
&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: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
&cli.StringFlag{Name: "icon", Aliases: []string{"i"}, EnvVars: []string{"NTFY_ICON"}, Usage: "URL to use as notification icon"},
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the 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: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
&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: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"env_topic", "P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
)
var cmdPublish = &cli.Command{
Name: "publish",
Aliases: []string{"pub", "send", "trigger"},
Usage: "Send message via a ntfy server",
UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]",
Action: execPublish,
Category: categoryClient,
Flags: flagsPublish,
Before: initLogFunc,
Name: "publish",
Aliases: []string{"pub", "send", "trigger"},
Usage: "Send message via a ntfy server",
UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...]
ntfy publish [OPTIONS..] --wait-cmd COMMAND...
NTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE...]`,
Action: execPublish,
Category: categoryClient,
Flags: flagsPublish,
Before: initLogFunc,
Description: `Publish a message to a ntfy server.
Examples:
@@ -56,11 +65,14 @@ Examples:
ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am
ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com
ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
ntfy pub -u phil:mypass secret Psst # Publish with username/password
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes
NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password
NTFY_TOPIC=mytopic ntfy pub -P "some message"" # Use NTFY_TOPIC variable as topic
NTFY_TOPIC=mytopic ntfy pub -P "some message" # Use NTFY_TOPIC variable as topic
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
ntfy trigger mywebhook # Sending without message, useful for webhooks
@@ -80,6 +92,7 @@ func execPublish(c *cli.Context) error {
tags := c.String("tags")
delay := c.String("delay")
click := c.String("click")
icon := c.String("icon")
actions := c.String("actions")
attach := c.String("attach")
filename := c.String("filename")
@@ -88,22 +101,11 @@ func execPublish(c *cli.Context) error {
user := c.String("user")
noCache := c.Bool("no-cache")
noFirebase := c.Bool("no-firebase")
envTopic := c.Bool("env-topic")
quiet := c.Bool("quiet")
var topic, message string
if envTopic {
topic = os.Getenv("NTFY_TOPIC")
if c.NArg() > 0 {
message = strings.Join(c.Args().Slice(), " ")
}
} else {
if c.NArg() < 1 {
return errors.New("must specify topic, type 'ntfy publish --help' for help")
}
topic = c.Args().Get(0)
if c.NArg() > 1 {
message = strings.Join(c.Args().Slice()[1:], " ")
}
pid := c.Int("wait-pid")
topic, message, command, err := parseTopicMessageCommand(c)
if err != nil {
return err
}
var options []client.PublishOption
if title != "" {
@@ -121,6 +123,9 @@ func execPublish(c *cli.Context) error {
if click != "" {
options = append(options, client.WithClick(click))
}
if icon != "" {
options = append(options, client.WithIcon(icon))
}
if actions != "" {
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
}
@@ -156,6 +161,21 @@ func execPublish(c *cli.Context) error {
}
options = append(options, client.WithBasicAuth(user, pass))
}
if pid > 0 {
newMessage, err := waitForProcess(pid)
if err != nil {
return err
} else if message == "" {
message = newMessage
}
} else if len(command) > 0 {
newMessage, err := runAndWaitForCommand(command)
if err != nil {
return err
} else if message == "" {
message = newMessage
}
}
var body io.Reader
if file == "" {
body = strings.NewReader(message)
@@ -188,3 +208,92 @@ func execPublish(c *cli.Context) error {
}
return nil
}
// parseTopicMessageCommand reads the topic and the remaining arguments from the context.
// There are a few cases to consider:
//
// ntfy publish <topic> [<message>]
// ntfy publish --wait-cmd <topic> <command>
// NTFY_TOPIC=.. ntfy publish [<message>]
// NTFY_TOPIC=.. ntfy publish --wait-cmd <command>
func parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) {
var args []string
topic, args, err = parseTopicAndArgs(c)
if err != nil {
return
}
if c.Bool("wait-cmd") {
if len(args) == 0 {
err = errors.New("must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help")
return
}
command = args
} else {
message = strings.Join(args, " ")
}
if c.String("message") != "" {
message = c.String("message")
}
return
}
func parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) {
envTopic := c.Bool("env-topic")
if envTopic {
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: The --env-topic/-P flag will be removed in July 2022, see https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
topic = os.Getenv("NTFY_TOPIC")
if topic == "" {
return "", nil, errors.New("when --env-topic is passed, must define NTFY_TOPIC environment variable")
}
return topic, remainingArgs(c, 0), nil
}
if c.NArg() < 1 {
return "", nil, errors.New("must specify topic, type 'ntfy publish --help' for help")
}
return c.Args().Get(0), remainingArgs(c, 1), nil
}
func remainingArgs(c *cli.Context, fromIndex int) []string {
if c.NArg() > fromIndex {
return c.Args().Slice()[fromIndex:]
}
return []string{}
}
func waitForProcess(pid int) (message string, err error) {
if !processExists(pid) {
return "", fmt.Errorf("process with PID %d not running", pid)
}
start := time.Now()
log.Debug("Waiting for process with PID %d to exit", pid)
for processExists(pid) {
time.Sleep(500 * time.Millisecond)
}
runtime := time.Since(start).Round(time.Millisecond)
log.Debug("Process with PID %d exited after %s", pid, runtime)
return fmt.Sprintf("Process with PID %d exited after %s", pid, runtime), nil
}
func runAndWaitForCommand(command []string) (message string, err error) {
prettyCmd := util.QuoteCommand(command)
log.Debug("Running command: %s", prettyCmd)
start := time.Now()
cmd := exec.Command(command[0], command[1:]...)
if log.IsTrace() {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
err = cmd.Run()
runtime := time.Since(start).Round(time.Millisecond)
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
log.Debug("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd)
return fmt.Sprintf("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd), nil
}
// Hard fail when command does not exist or could not be properly launched
return "", fmt.Errorf("command failed: %s, error: %s", prettyCmd, err.Error())
}
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
}

View File

@@ -5,7 +5,11 @@ import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/test"
"heckel.io/ntfy/util"
"os"
"os/exec"
"strconv"
"testing"
"time"
)
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
@@ -48,6 +52,7 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
"--tags", "tag1,tag2",
// No --delay, --email
"--click", "https://ntfy.sh",
"--icon", "https://ntfy.sh/static/img/ntfy.png",
"--attach", "https://f-droid.org/F-Droid.apk",
"--filename", "fdroid.apk",
"--no-cache",
@@ -69,4 +74,68 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
require.Equal(t, "", m.Attachment.Owner)
require.Equal(t, int64(0), m.Attachment.Expires)
require.Equal(t, "", m.Attachment.Type)
require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
}
func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
s, port := test.StartServer(t)
defer test.StopServer(t, s, port)
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
// Test: sleep 0.5
sleep := exec.Command("sleep", "0.5")
require.Nil(t, sleep.Start())
go sleep.Wait() // Must be called to release resources
start := time.Now()
app, _, stdout, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid), topic}))
m := toMessage(t, stdout.String())
require.True(t, time.Since(start) >= 500*time.Millisecond)
require.Regexp(t, `Process with PID \d+ exited after `, m.Message)
// Test: PID does not exist
app, _, _, _ = newTestApp()
err := app.Run([]string{"ntfy", "publish", "--wait-pid", "1234567", topic})
require.Error(t, err)
require.Equal(t, "process with PID 1234567 not running", err.Error())
// Test: Successful command (exit 0)
start = time.Now()
app, _, stdout, _ = newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "sleep", "0.5"}))
m = toMessage(t, stdout.String())
require.True(t, time.Since(start) >= 500*time.Millisecond)
require.Contains(t, m.Message, `Command succeeded after `)
require.Contains(t, m.Message, `: sleep 0.5`)
// Test: Failing command (exit 1)
app, _, stdout, _ = newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "/bin/false", "false doesn't care about its args"}))
m = toMessage(t, stdout.String())
require.Contains(t, m.Message, `Command failed after `)
require.Contains(t, m.Message, `(exit code 1): /bin/false "false doesn't care about its args"`, m.Message)
// Test: Non-existing command (hard fail!)
app, _, _, _ = newTestApp()
err = app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "does-not-exist-no-really", "really though"})
require.Error(t, err)
require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
// Tests with NTFY_TOPIC set ////
require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
// Test: Successful command with NTFY_TOPIC
app, _, stdout, _ = newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
m = toMessage(t, stdout.String())
require.Equal(t, "mytopic", m.Topic)
// Test: Successful --wait-pid with NTFY_TOPIC
sleep = exec.Command("sleep", "0.2")
require.Nil(t, sleep.Start())
go sleep.Wait() // Must be called to release resources
app, _, stdout, _ = newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
m = toMessage(t, stdout.String())
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
}

11
cmd/publish_unix.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build darwin || linux || dragonfly || freebsd || netbsd || openbsd
// +build darwin linux dragonfly freebsd netbsd openbsd
package cmd
import "syscall"
func processExists(pid int) bool {
err := syscall.Kill(pid, syscall.Signal(0))
return err == nil
}

10
cmd/publish_windows.go Normal file
View File

@@ -0,0 +1,10 @@
package cmd
import (
"os"
)
func processExists(pid int) bool {
_, err := os.FindProcess(pid)
return err == nil
}

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"heckel.io/ntfy/log"
"io/fs"
"math"
"net"
"os"
@@ -35,11 +36,13 @@ var flagsServe = append(
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-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
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: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
@@ -98,11 +101,13 @@ func execServe(c *cli.Context) error {
listenHTTP := c.String("listen-http")
listenHTTPS := c.String("listen-https")
listenUnix := c.String("listen-unix")
listenUnixMode := c.Int("listen-unix-mode")
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
cacheStartupQueries := c.String("cache-startup-queries")
authFile := c.String("auth-file")
authDefaultAccess := c.String("auth-default-access")
attachmentCacheDir := c.String("attachment-cache-dir")
@@ -152,8 +157,8 @@ func execServe(c *cli.Context) error {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
return errors.New("if attachment-cache-dir is set, base-url must also be set")
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return errors.New("if set, base-url must start with http:// or https://")
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") && strings.HasSuffix(baseURL, "/") {
return errors.New("if set, base-url must start with http:// or https://, and must not end with a slash (/)")
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) {
@@ -162,6 +167,8 @@ func execServe(c *cli.Context) error {
return errors.New("if set, upstream-base-url must start with http:// or https://")
} else if upstreamBaseURL != "" && baseURL == "" {
return errors.New("if upstream-base-url is set, base-url must also be set")
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
}
webRootIsApp := webRoot == "app"
@@ -215,11 +222,13 @@ func execServe(c *cli.Context) error {
conf.ListenHTTP = listenHTTP
conf.ListenHTTPS = listenHTTPS
conf.ListenUnix = listenUnix
conf.ListenUnixMode = fs.FileMode(listenUnixMode)
conf.KeyFile = keyFile
conf.CertFile = certFile
conf.FirebaseKeyFile = firebaseKeyFile
conf.CacheFile = cacheFile
conf.CacheDuration = cacheDuration
conf.CacheStartupQueries = cacheStartupQueries
conf.AuthFile = authFile
conf.AuthDefaultRead = authDefaultRead
conf.AuthDefaultWrite = authDefaultWrite
@@ -249,6 +258,7 @@ func execServe(c *cli.Context) error {
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.BehindProxy = behindProxy
conf.EnableWeb = enableWeb
conf.Version = c.App.Version
// Set up hot-reloading of config
go sigHandlerConfigReload(config)

View File

@@ -30,7 +30,7 @@ var flagsSubscribe = append(
&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: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
&cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
)
@@ -175,11 +175,28 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
for filter, value := range s.If {
topicOptions = append(topicOptions, client.WithFilter(filter, value))
}
if s.User != "" && s.Password != "" {
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
var user, password string
if s.User != "" {
user = s.User
} else if conf.DefaultUser != "" {
user = conf.DefaultUser
}
if s.Password != "" {
password = s.Password
} else if conf.DefaultPassword != "" {
password = conf.DefaultPassword
}
if user != "" && password != "" {
topicOptions = append(topicOptions, client.WithBasicAuth(user, password))
}
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
cmds[subscriptionID] = s.Command
if s.Command != "" {
cmds[subscriptionID] = s.Command
} else if conf.DefaultCommand != "" {
cmds[subscriptionID] = conf.DefaultCommand
} else {
cmds[subscriptionID] = ""
}
}
if topic != "" {
subscriptionID := cl.Subscribe(topic, options...)

View File

@@ -1,3 +1,6 @@
//go:build linux || dragonfly || freebsd || netbsd || openbsd
// +build linux dragonfly freebsd netbsd openbsd
package cmd
const (

View File

@@ -6,11 +6,13 @@ import (
"crypto/subtle"
"errors"
"fmt"
"os"
"strings"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"strings"
)
func init() {
@@ -19,9 +21,9 @@ func init() {
var flagsUser = append(
flagsDefault,
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
&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-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"}),
)
var cmdUser = &cli.Command{
@@ -36,7 +38,7 @@ var cmdUser = &cli.Command{
Name: "add",
Aliases: []string{"a"},
Usage: "Adds a new user",
UsageText: "ntfy user add [--role=admin|user] USERNAME",
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME",
Action: execUserAdd,
Flags: []cli.Flag{
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
@@ -48,8 +50,12 @@ granted otherwise by the auth-default-access setting). An admin user has read an
topics.
Examples:
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts)
You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if
you are creating users via scripts.
`,
},
{
@@ -68,7 +74,7 @@ Example:
Name: "change-pass",
Aliases: []string{"chp"},
Usage: "Changes a user's password",
UsageText: "ntfy user change-pass USERNAME",
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME",
Action: execUserChangePass,
Description: `Change the password for the given user.
@@ -76,7 +82,12 @@ The new password will be read from STDIN, and it'll be confirmed by typing
it twice.
Example:
ntfy user change-pass phil
ntfy user change-pass phil
NTFY_PASSWORD=.. ntfy user change-pass phil
You may set the NTFY_PASSWORD environment variable to pass the new password. This is
useful if you are updating users via scripts.
`,
},
{
@@ -125,18 +136,24 @@ The command allows you to add/remove/change users in the ntfy user database, as
passwords or roles.
Examples:
ntfy user list # Shows list of users (alias: 'ntfy access')
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
ntfy user del phil # Delete 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 list # Shows list of users (alias: 'ntfy access')
ntfy user add phil # Add regular user phil
NTFY_PASSWORD=... ntfy user add phil # As above, using env variable to set password (for scripts)
ntfy user add --role=admin phil # Add admin user phil
ntfy user del phil # Delete user phil
ntfy user change-pass phil # Change password for user phil
NTFY_PASSWORD=.. ntfy user change-pass phil # As above, using env variable to set password (for scripts)
ntfy user change-role phil admin # Make user phil an admin
For the 'ntfy user add' and 'ntfy user change-pass' commands, you may set the NTFY_PASSWORD environment
variable to pass the new password. This is useful if you are creating/updating users via scripts.
`,
}
func execUserAdd(c *cli.Context) error {
username := c.Args().Get(0)
role := auth.Role(c.String("role"))
password := os.Getenv("NTFY_PASSWORD")
if username == "" {
return errors.New("username expected, type 'ntfy user add --help' for help")
} else if username == userEveryone {
@@ -151,9 +168,13 @@ func execUserAdd(c *cli.Context) error {
if user, _ := manager.User(username); user != nil {
return fmt.Errorf("user %s already exists", username)
}
password, err := readPasswordAndConfirm(c)
if err != nil {
return err
if password == "" {
p, err := readPasswordAndConfirm(c)
if err != nil {
return err
}
password = p
}
if err := manager.AddUser(username, password, role); err != nil {
return err
@@ -185,6 +206,7 @@ func execUserDel(c *cli.Context) error {
func execUserChangePass(c *cli.Context) error {
username := c.Args().Get(0)
password := os.Getenv("NTFY_PASSWORD")
if username == "" {
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
} else if username == userEveryone {
@@ -197,9 +219,11 @@ func execUserChangePass(c *cli.Context) error {
if _, err := manager.User(username); err == auth.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)
}
password, err := readPasswordAndConfirm(c)
if err != nil {
return err
if password == "" {
password, err = readPasswordAndConfirm(c)
if err != nil {
return err
}
}
if err := manager.ChangePassword(username, password); err != nil {
return err

View File

@@ -733,6 +733,21 @@ out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/
Depending on *how you run it*, here are a few limits that are relevant:
### WAL for message cache
By default, the [message cache](#message-cache) (defined by `cache-file`) uses the SQLite default settings, which means it
syncs to disk on every write. For personal servers, this is perfectly adequate. For larger installations, such as ntfy.sh,
the [write-ahead log (WAL)](https://sqlite.org/wal.html) should be enabled, and the sync mode should be adjusted.
See [this article](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) for details.
Here's how ntfy.sh has been tuned in the `server.yml` file:
``` yaml
cache-startup-queries: |
pragma journal_mode = WAL;
pragma synchronous = normal;
pragma temp_store = memory;
```
### For systemd services
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it
@@ -790,9 +805,25 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
=== "/etc/nginx/nginx.conf"
```
# Rate limit all IP addresses
http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
}
# Alternatively, whitelist certain IP addresses
http {
geo $limited {
default 1;
116.203.112.46/32 0;
132.226.42.65/32 0;
...
}
map $limited $limitkey {
1 $binary_remote_addr;
0 "";
}
limit_req_zone $limitkey zone=one:10m rate=1r/s;
}
```
=== "/etc/nginx/sites-enabled/ntfy.sh"
@@ -860,11 +891,13 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 |
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#wal-for-message-cache) |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
@@ -929,6 +962,7 @@ OPTIONS:
--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]
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]

View File

@@ -1,29 +1,43 @@
# Deprecation notices
This page is used to list deprecation notices for ntfy. Deprecated commands and options will be
**removed after ~3 months** from the time they were deprecated.
**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated
before the behavior is changed depends on the severity of the change, and how prominent the feature is.
## Active deprecations
### Android app: WebSockets will become the default connection protocol
> Active since 2022-03-13, behavior will change in **June 2022**
### ntfy CLI: `ntfy publish --env-topic` will be removed
> Active since 2022-06-20, behavior will change end of **July 2022**
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
The `ntfy publish --env-topic` option will be removed. It'll still be possible to specify a topic via the
`NTFY_TOPIC` environment variable, but it won't be necessary anymore to specify the `--env-topic` flag.
Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely
seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to
WebSocket in June.
=== "Before"
```
$ NTFY_TOPIC=mytopic ntfy publish --env-topic "this is the message"
```
### Android app: Using `since=<timestamp>` instead of `since=<id>`
> Active since 2022-02-27, behavior will change in **May 2022**
In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will
not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
=== "After"
```
$ NTFY_TOPIC=mytopic ntfy publish "this is the message"
```
## Previous deprecations
### <del>Android app: WebSockets will become the default connection protocol</del>
> Active since 2022-03-13, behavior will not change (deprecation removed 2022-06-20)
Instant delivery connections and connections to self-hosted servers in the Android app were going to switch
to use the WebSockets protocol by default. It was decided to keep JSON stream as the most compatible default
and add a notice banner in the Android app instead.
### Android app: Using `since=<timestamp>` instead of `since=<id>`
> Active since 2022-02-27, behavior changed with v1.14.0
The Android app started using `since=<id>` instead of `since=<timestamp>`, which means as of Android app v1.14.0,
it will not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
### Running server via `ntfy` (instead of `ntfy serve`)
> Deprecated 2021-12-17, behavior changed with v1.10.0

View File

@@ -59,7 +59,7 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
``` shell
wget https://go.dev/dl/go1.18.linux-amd64.tar.gz
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
go version # verifies that it worked
```

View File

@@ -9,7 +9,9 @@ those out, too.
[create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that
I cannot guarantee that all of these examples are functional. Many of them I have not tried myself.
## A long process is done: backups, copying data, pipelines, ...
## Cronjobs
ntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...).
I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
@@ -21,6 +23,15 @@ rsync -a root@laptop /backups/laptop \
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
```
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
``` cron
# Check github/ntfy user
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
```
## Low disk space alerts
Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but
effective.
@@ -42,11 +53,7 @@ if [ -n "$avail" ]; then
fi
```
## Server-sent messages in your web app
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
web application. Check out the <a href="/example.html">live example</a>.
## Notify on SSH login
## SSH login alerts
Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I
own, I now message myself. Here's an example of how to use <a href="https://en.wikipedia.org/wiki/Linux_PAM">PAM</a>
to notify yourself on SSH login.
@@ -102,7 +109,7 @@ One of my co-workers uses the following Ansible task to let him know when things
body: "{{ inventory_hostname }} reseeding complete"
```
## Watchtower notifications (shoutrrr)
## Watchtower (shoutrrr)
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
@@ -121,16 +128,7 @@ Or, if you only want to send notifications using shoutrrr:
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
```
## Random cronjobs
Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
``` cron
# Check github/ntfy user
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
```
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
@@ -343,7 +341,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
![Node red picture flow](static/img/nodered-picture.png)
## Gatus service health check
## Gatus
An example for a custom alert with [Gatus](https://github.com/TwiN/gatus):
``` yaml
@@ -433,3 +431,76 @@ notify:
message_param_name: message
resource: https://ntfy.sh
```
## Uptime Kuma
Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**.
Then set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)**:
<div id="uptimekuma-screenshots" class="screenshots">
<a href="../static/img/uptimekuma-settings.png"><img src="../static/img/uptimekuma-settings.png"/></a>
<a href="../static/img/uptimekuma-setup.png"><img src="../static/img/uptimekuma-setup.png"/></a>
</div>
You can now test the notifications and apply them to monitors:
<div id="uptimekuma-monitor-screenshots" class="screenshots">
<a href="../static/img/uptimekuma-ios-test.jpg"><img src="../static/img/uptimekuma-ios-test.jpg"/></a>
<a href="../static/img/uptimekuma-ios-down.jpg"><img src="../static/img/uptimekuma-ios-down.jpg"/></a>
<a href="../static/img/uptimekuma-ios-up.jpg"><img src="../static/img/uptimekuma-ios-up.jpg"/></a>
</div>
## UptimeRobot
Go to your [UptimeRobot](https://github.com/uptimerobot) My Settings > Alert Contacts > Add Alert Contact
Select **Alert Contact Type** = Webhook. Then set your desired **Friendly Name** (e.g. "ntfy-sh-UP"), **URL to Notify**, **POST value** and select checkbox **Send as JSON (application/json)**. Make sure to send the JSON POST request to ntfy.domain.com without the topic name in the url and include the "topic" name in the JSON body.
<div id="uptimerobot-monitor-setup" class="screenshots">
<a href="../static/img/uptimerobot-setup.jpg"><img src="../static/img/uptimerobot-setup.jpg"/></a>
</div>
``` json
{
"topic":"myTopic",
"title": "*monitorFriendlyName* *alertTypeFriendlyName*",
"message": "*alertDetails*",
"tags": ["green_circle"],
"priority": 3,
"click": https://uptimerobot.com/dashboard#*monitorID*
}
```
You can create two Alert Contacts each with a different icon and priority, for example:
``` json
{
"topic":"myTopic",
"title": "*monitorFriendlyName* *alertTypeFriendlyName*",
"message": "*alertDetails*",
"tags": ["red_circle"],
"priority": 3,
"click": https://uptimerobot.com/dashboard#*monitorID*
}
```
You can now add the created Alerts Contact(s) to the monitor(s) and test the notifications:
<div id="uptimerobot-monitor-screenshots" class="screenshots">
<a href="../static/img/uptimerobot-test.jpg"><img src="../static/img/uptimerobot-test.jpg"/></a>
</div>
## Apprise
ntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the
[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)).
You can use it like this:
```
apprise -vv -t "Test Message Title" -b "Test Message Body" \
ntfy://mytopic
```
Or with your own server like this:
```
apprise -vv -t "Test Message Title" -b "Test Message Body" \
ntfy://ntfy.example.com/mytopic
```

View File

@@ -11,17 +11,16 @@ the service.
Best effort.
## What happens if there are multiple subscribers to the same topic?
As per usual with pub-sub, all subscribers receive notifications if they are
subscribed to a topic.
As per usual with pub-sub, all subscribers receive notifications if they are subscribed to a topic.
## Will you know what topics exist, can you spy on me?
If you don't trust me or your messages are sensitive, run your own server. It's <a href="https://github.com/binwiederhier/ntfy">open source</a>.
That said, the logs do not contain any topic names or other details about you.
Messages are cached for the duration configured in `server.yml` (12h by default) to facilitate service restarts, message polling and to overcome
client network disruptions.
If you don't trust me or your messages are sensitive, run your own server. It's open source.
That said, the logs do contain topic names and IP addresses, but I don't use them for anything other than
troubleshooting and rate limiting. Messages are cached for the duration configured in `server.yml` (12h by default)
to facilitate service restarts, message polling and to overcome client network disruptions.
## Can I self-host it?
Yes. The server (including this Web UI) can be self-hosted, and the Android app supports adding topics from
Yes. The server (including this Web UI) can be self-hosted, and the Android/iOS app supports adding topics from
your own server as well. Check out the [install instructions](install.md).
## Why is Firebase used?
@@ -34,16 +33,17 @@ of the app and [self-host your own ntfy server](install.md).
## How much battery does the Android app use?
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes 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
the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
or you use *instant delivery* (Android only), the app has to maintain a constant connection to the server, which consumes
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.
## 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
server and listens for incoming notifications. This consumes <a href="#battery-usage">additional battery</a>,
server and listens for incoming notifications. This consumes additional battery (see above),
but delivers notifications instantly.
## Why is there no iOS app (yet)?
I don't have an iPhone or a Mac, so I didn't make an iOS app yet. It'd be awesome if
<a href="https://github.com/binwiederhier/ntfy/issues/4">someone else could help out</a>.
## Where can I donate?
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
appreciated.

View File

@@ -26,37 +26,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_x86_64.tar.gz
tar zxvf ntfy_1.25.1_linux_x86_64.tar.gz
sudo cp -a ntfy_1.25.1_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_x86_64.tar.gz
tar zxvf ntfy_1.28.0_linux_x86_64.tar.gz
sudo cp -a ntfy_1.28.0_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.28.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv6.tar.gz
tar zxvf ntfy_1.25.1_linux_armv6.tar.gz
sudo cp -a ntfy_1.25.1_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_armv6.tar.gz
tar zxvf ntfy_1.28.0_linux_armv6.tar.gz
sudo cp -a ntfy_1.28.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.28.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv7.tar.gz
tar zxvf ntfy_1.25.1_linux_armv7.tar.gz
sudo cp -a ntfy_1.25.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_armv7.tar.gz
tar zxvf ntfy_1.28.0_linux_armv7.tar.gz
sudo cp -a ntfy_1.28.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.28.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_arm64.tar.gz
tar zxvf ntfy_1.25.1_linux_arm64.tar.gz
sudo cp -a ntfy_1.25.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.1_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_arm64.tar.gz
tar zxvf ntfy_1.28.0_linux_arm64.tar.gz
sudo cp -a ntfy_1.28.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.28.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -103,7 +103,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -111,7 +111,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -119,7 +119,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -127,7 +127,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -137,28 +137,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_1.25.1_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -184,33 +184,38 @@ nix-env -iA ntfy-sh
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_macOS_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_v1.25.1_macOS_all.tar.gz > ntfy_v1.25.1_macOS_all.tar.gz
tar zxvf ntfy_v1.25.1_macOS_all.tar.gz
sudo cp -a ntfy_v1.25.1_macOS_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_macOS_all.tar.gz > ntfy_1.28.0_macOS_all.tar.gz
tar zxvf ntfy_1.28.0_macOS_all.tar.gz
sudo cp -a ntfy_1.28.0_macOS_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_v1.25.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_1.28.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
!!! info
If there is a desire to install ntfy via [Homebrew](https://brew.sh/), please create a
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. Also, you can build and run the
ntfy server on macOS as well, though I don't officially support that. Check out the [build instructions](develop.md)
for details.
There is a [GitHub issue](https://github.com/binwiederhier/ntfy/issues/286) about making ntfy installable via
[Homebrew](https://brew.sh/). I'll eventually get to that, but I'd also love if somebody else stepped up to do it.
Also, you can build and run the ntfy server on macOS as well, though I don't officially support that.
Check out the [build instructions](develop.md) for details.
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.25.1/ntfy_v1.25.1_windows_x86_64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.28.0/ntfy_1.28.0_windows_x86_64.zip),
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).
Also available in [Scoop's](https://scoop.sh) Main repository:
`scoop install ntfy`
!!! info
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
@@ -223,6 +228,11 @@ The server exposes its web UI and the API on port 80, so you need to expose that
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
you should map `/etc/ntfy`, so you can edit `/etc/ntfy/server.yml`.
!!! info
Note that the Docker image **does not contain a `/etc/ntfy/server.yml` file**. If you'd like to use a config file,
please manually create one outside the image and map it as a volume, e.g. via `-v /etc/ntfy:/etc/ntfy`. You may
use the [`server.yml` file on GitHub](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml) as a template.
Basic usage (no cache or additional config):
```
docker run -p 80:80 -it binwiederhier/ntfy serve
@@ -235,8 +245,8 @@ docker run \
-p 80:80 \
-it \
binwiederhier/ntfy \
--cache-file /var/cache/ntfy/cache.db \
serve
serve \
--cache-file /var/cache/ntfy/cache.db
```
With other config options, timezone, and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):

100
docs/integrations.md Normal file
View File

@@ -0,0 +1,100 @@
# Integrations + community projects
There are quite a few projects that work with ntfy, integrate ntfy, or have been built around ntfy. It's super exciting to see what you guys have come up with. Feel free to [create a pull request on GitHub](https://github.com/binwiederhier/ntfy/issues) to add your own project here.
I've added a ⭐ to projects or posts that have a significant following, or had a lot of interaction by the community.
## Public ntfy servers
| URL | Country |
|-----------------------------------------------|:---------:|
| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 |
| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 🇪🇺 |
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 🇪🇺 |
Thanks to everyone running a public server. **You guys rock!** To the users: Be aware that server operators can log your
messages until I finally finish implementing end-to-end encryption.
## Official integrations
- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push Notifications that work with just about every platform
- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool
- [Robusta](https://docs.robusta.dev/master/catalog/sinks/webhook.html) ⭐ - open source platform for Kubernetes troubleshooting
- [borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#third-party-monitoring-services) ⭐ - configuration-driven backup software for servers and workstations
- [Radarr](https://radarr.video/) ⭐ - Movie collection manager for Usenet and BitTorrent users
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
- [Element](https://f-droid.org/packages/im.vector.app/) ⭐ - Matrix client
- [SchildiChat](https://schildi.chat/android/) ⭐ - Matrix client
- [Tusky](https://tusky.app/) ⭐ - Fediverse client
- [Fedilab](https://fedilab.app/) - Fediverse client
- [FindMyDevice](https://gitlab.com/Nulide/findmydevice/) - Find your Device with an SMS or online with the help of FMDServer
- [Tox Push Message App](https://github.com/zoff99/tox_push_msg_app) - Tox Push Message App
## Libraries
- [ntfy-php-library](https://github.com/VerifiedJoseph/ntfy-php-library) - PHP library for sending messages using a ntfy server (PHP)
- [ntfy-notifier](https://github.com/DAcodedBEAT/ntfy-notifier) - Symfony Notifier integration for ntfy (PHP)
- [ntfpy](https://github.com/Nevalicjus/ntfpy) - API Wrapper for ntfy.sh (Python)
- [pyntfy](https://github.com/DP44/pyntfy) - A module for interacting with ntfy notifications (Python)
- [vntfy](https://github.com/lmangani/vntfy) - Barebone V client for ntfy (V)
- [ntfy-middleman](https://github.com/nachotp/ntfy-middleman) - Wraps APIs and send notifications using ntfy.sh on schedule (Python)
## CLIs + GUIs
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
- [ntfy Desktop client](https://github.com/mininmobile/ntfy-desktop) - Cross-platform desktop application for ntfy
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
## Projects + scripts
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
- [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)
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
- [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs)
- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)
- [grav-plugin-whistleblower](https://github.com/Himmlisch-Studios/grav-plugin-whistleblower) - Grav CMS plugin to get notifications via ntfy (PHP)
- [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C)
- [borg-based backup](https://github.com/davidhi7/backup) - Simple borg-based backup script with notifications based on ntfy.sh or Discord webhooks (Python/Shell)
- [ntfy.sh *arr script](https://github.com/agent-squirrel/nfty-arr-script) - Quick and hacky script to get sonarr/radarr to notify the ntfy.sh service (Shell)
- [siteeagle](https://github.com/tpanum/siteeagle) - A small Python script to monitor websites and notify changes (Python)
- [send_to_phone](https://github.com/whipped-cream/send_to_phone) - Scripts to upload a file to Transfer.sh and ping ntfy with the download link (Python)
- [ntfy Discord bot](https://github.com/R0dn3yS/ntfy-bot) - WIP ntfy discord bot (TypeScript)
- [ntfy Discord bot](https://github.com/binwiederhier/ntfy-bot) - ntfy Discord bot (Go)
- [Bettarr Notifications](https://github.com/NiNiyas/Bettarr-Notifications) - Better Notifications for Sonarr and Radarr (Python)
- [Notify me the intruders](https://github.com/nothingbutlucas/notify_me_the_intruders) - Notify you if they are intruders or new connections on your network (Shell)
- [Send GitHub Action to ntfy](https://github.com/NiNiyas/ntfy-action) - Send GitHub Action workflow notifications to ntfy (JS)
- [ntfy alertmanager bridge](https://github.com/aTable/ntfy_alertmanager_bridge) - Basic alertmanager bridge to ntfy (JS)
- [restreamchat2ntfy](https://github.com/kurohuku7/restreamchat2ntfy) - Send restream.io chat to ntfy to check on the Meta Quest (JS)
- [k8s-ntfy-deployment-service](https://github.com/Christian42/k8s-ntfy-deployment-service) - Automatic Kubernetes (k8s) ntfy deployment
## Blog + forum posts
- [Self hosted Mobile Push Notifications using NTFY | Thejesh GN](https://thejeshgn.com/2022/08/23/self-hosted-mobile-push-notifications-using-ntfy/) - 8/2022
- [Fedora Magazine | 4 cool new projects to try in Copr](https://fedoramagazine.org/4-cool-new-projects-to-try-in-copr-for-august-2022/) - 8/2022
- [Docker로 오픈소스 푸시알람 프로젝트 ntfy.sh 설치 및 사용하기.(Feat. Uptimekuma)](https://svrforum.com/svr/398979) - 8/2022
- [Easy notifications from R](https://sometimesir.com/posts/easy-notifications-from-r/) - 6/2022
- [ntfy is finally coming to iOS, and Matrix/UnifiedPush gateway support](https://www.reddit.com/r/selfhosted/comments/vdzvxi/ntfy_is_finally_coming_to_ios_with_full/) ⭐ - 6/2022
- [无需注册的通知服务ntfy](https://wbsu2003.4everland.app/2022/05/30/%E6%97%A0%E9%9C%80%E6%B3%A8%E5%86%8C%E7%9A%84%E9%80%9A%E7%9F%A5%E6%9C%8D%E5%8A%A1ntfy/) - 5/2022
- [Install guide (with Docker)](https://chowdera.com/2022/150/202205301257379077.html) - 5/2022
- [Updated review post (Jan-Lukas Else)](https://jlelse.blog/thoughts/2022/04/ntfy) - 4/2022
- [Reddit feature update post](https://www.reddit.com/r/selfhosted/comments/uetlso/ntfy_is_a_tool_to_send_push_notifications_to_your/) ⭐ - 4/2022
- [無料で簡単に通知の送受信ができつつオープンソースでセルフホストも可能な「ntfy」を使ってみた (Gigazine)](https://gigazine.net/news/20220404-ntfy-push-notification/) - 4/2022
- [Pocketmags ntfy review](https://pocketmags.com/us/linux-format-magazine/march-2022/articles/1104187/ntfy) - 3/2022
- [Reddit web app release post](https://www.reddit.com/r/selfhosted/comments/tc0p0u/say_hello_to_the_brand_new_ntfysh_web_app_push/) ⭐ - 3/2022
- [Lemmy post (Jakob)](https://lemmy.eus/post/15541) - 1/2022
- [Reddit UnifiedPush release post](https://www.reddit.com/r/selfhosted/comments/s5jylf/my_open_source_notification_android_app_and/) ⭐ - 1/2022
- [ntfy: send notifications from your computer to your phone](https://rs1.es/tutorials/2022/01/19/ntfy-send-notifications-phone.html) - 1/2022
- [Short ntfy review (Jan-Lukas Else)](https://jlelse.blog/links/2021/12/ntfy-sh) - 12/2021
- [Free MacroDroid webhook alternative (FrameXX)](https://www.macrodroidforum.com/index.php?threads/ntfy-sh-free-macrodroid-webhook-alternative.1505/) - 12/2021
- [ntfy otro sistema de notificaciones pub-sub simple basado en HTTP](https://ugeek.github.io/blog/post/2021-11-05-ntfy-sh-otro-sistema-de-notificaciones-pub-sub-simple-basado-en-http.html) - 11/2021
- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - 12/2021
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - 11/2021

View File

@@ -885,16 +885,22 @@ is the only required one:
``` powershell
$uri = "https://ntfy.sh"
$body = @{
"topic"="powershell"
"title"="Low disk space alert"
"message"="Disk space is low at 5.1 GB"
"priority"=4
"attach"="https://filesrv.lan/space.jpg"
"filename"="diskspace.jpg"
"tags"=@("warning","cd")
"click"= "https://homecamera.lan/xasds1h2xsSsa/"
"actions"=@[@{ "action"="view", "label"="Admin panel", "url"="https://filesrv.lan/admin" }]
} | ConvertTo-Json
topic = "mytopic"
title = "Low disk space alert"
message = "Disk space is low at 5.1 GB"
priority = 4
attach = "https://filesrv.lan/space.jpg"
filename = "diskspace.jpg"
tags = @("warning", "cd")
click = "https://homecamera.lan/xasds1h2xsSsa/"
actions = @(
@{
action = "view"
label = "Admin panel"
url = "https://filesrv.lan/admin"
}
)
} | ConvertTo-Json
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
@@ -1160,7 +1166,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
method: 'POST',
body: JSON.stringify({
topic: "myhome",
message": "You left the house. Turn down the A/C?",
message: "You left the house. Turn down the A/C?",
actions: [
{
action: "view",
@@ -1210,20 +1216,20 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
``` powershell
$uri = "https://ntfy.sh"
$body = @{
"topic"="myhome"
"message"="You left the house. Turn down the A/C?"
"actions"=@(
topic = "myhome"
message = "You left the house. Turn down the A/C?"
actions = @(
@{
"action"="view"
"label"="Open portal"
"url"="https://home.nest.com/"
"clear"=true
action = "view"
label = "Open portal"
url = "https://home.nest.com/"
clear = $true
},
@{
"action"="http",
"label"="Turn down"
"url"="https://api.nest.com/"
"body"="{\"temperature\": 65}"
action = "http"
label = "Turn down"
url = "https://api.nest.com/"
body = '{"temperature": 65}'
}
)
} | ConvertTo-Json
@@ -1470,9 +1476,9 @@ And the same example using [JSON publishing](#publish-as-json):
``` powershell
$uri = "https://ntfy.sh"
$body = @{
"topic"="myhome"
"message"="Somebody retweetet your tweet."
"actions"=@(
topic = "myhome"
message = "Somebody retweetet your tweet."
actions = @(
@{
"action"="view"
"label"="Open Twitter"
@@ -1725,21 +1731,24 @@ And the same example using [JSON publishing](#publish-as-json):
=== "PowerShell"
``` powershell
# Powershell requires the 'Depth' argument to equal 3 here to expand 'Extras',
# otherwise it will read System.Collections.Hashtable in the returned JSON
$uri = "https://ntfy.sh"
$body = @{
"topic"="wifey"
"message"="Your wife requested you send a picture of yourself."
"actions"=@(
topic = "wifey"
message = "Your wife requested you send a picture of yourself."
actions = @(
@{
"action"="broadcast"
"label"="Take picture"
"extras"=@{
"cmd"="pic"
"camera"="front"
action = "broadcast"
label = "Take picture"
extras = @{
cmd ="pic"
camera = "front"
}
}
)
} | ConvertTo-Json
} | ConvertTo-Json -Depth 3
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
@@ -1993,24 +2002,26 @@ And the same example using [JSON publishing](#publish-as-json):
=== "PowerShell"
``` powershell
# Powershell requires the 'Depth' argument to equal 3 here to expand 'headers',
# otherwise it will read System.Collections.Hashtable in the returned JSON
$uri = "https://ntfy.sh"
$body = @{
"topic"="myhome"
"message"="Garage door has been open for 15 minutes. Close it?"
"actions"=@(
topic = "myhome"
message = "Garage door has been open for 15 minutes. Close it?"
actions = @(
@{
"action"="http",
"label"="Close door"
"url"="https://api.mygarage.lan/"
"method"="PUT"
"headers"=@{
"Authorization"="Bearer zAzsx1sk.."
action = "http"
label = "Close door"
url = "https://api.mygarage.lan/"
method = "PUT"
headers = @{
Authorization = "Bearer zAzsx1sk.."
}
"body"="{\"action\": \"close\"}"
body = '{"action": "close"}'
}
}
)
} | ConvertTo-Json
} | ConvertTo-Json -Depth 3
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
@@ -2209,8 +2220,8 @@ Here's an example showing how to upload an image:
Host: ntfy.sh
Filename: flower.jpg
Content-Type: 52312
<binary JPEG data>
(binary JPEG data)
```
=== "JavaScript"
@@ -2338,6 +2349,112 @@ Here's an example showing how to attach an APK file:
<figcaption>File attachment sent from an external URL</figcaption>
</figure>
## Icons
_Supported on:_ :material-android:
You can include an icon that will appear next to the text of the notification. Simply pass the `X-Icon` header or query
parameter (or its alias `Icon`) to specify the URL that the icon is located at. The client will automatically download
the icon (unless it is already cached locally, and less than 24 hours old), and show it in the notification. Icons are
cached locally in the client until the notification is deleted. **Only JPEG and PNG images are supported at this time**.
Here's an example showing how to include an icon:
=== "Command line (curl)"
```
curl \
-H "Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
-H "Title: Kodi: Resuming Playback" \
-H "Tags: arrow_forward" \
-d "The Wire, S01E01" \
ntfy.sh/tvshows
```
=== "ntfy CLI"
```
ntfy publish \
--icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" \
--title="Kodi: Resuming Playback" \
--tags="arrow_forward" \
tvshows \
"The Wire, S01E01"
```
=== "HTTP"
``` http
POST /tvshows HTTP/1.1
Host: ntfy.sh
Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png
Tags: arrow_forward
Title: Kodi: Resuming Playback
The Wire, S01E01
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh/tvshows', {
method: 'POST',
headers: {
'Icon': 'https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png',
'Title': 'Kodi: Resuming Playback',
'Tags': 'arrow_forward'
},
body: "The Wire, S01E01"
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.sh/tvshows", strings.NewReader("The Wire, S01E01"))
req.Header.Set("Icon", "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png")
req.Header.Set("Tags", "arrow_forward")
req.Header.Set("Title", "Kodi: Resuming Playback")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/tvshows"
$headers = @{ Title"="Kodi: Resuming Playback"
Tags="arrow_forward"
Icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" }
$body = "The Wire, S01E01"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/tvshows",
data="The Wire, S01E01",
headers={
"Title": "Kodi: Resuming Playback",
"Tags": "arrow_forward",
"Icon": "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/tvshows', false, stream_context_create([
'http' => [
'method' => 'PUT',
'header' =>
"Content-Type: text/plain\r\n" . // Does not matter
"Title: Kodi: Resuming Playback\r\n" .
"Tags: arrow_forward\r\n" .
"Icon: https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png",
],
'content' => "The Wire, S01E01"
]));
```
Here's an example of how it will look on Android:
<figure markdown>
![file attachment](static/img/android-screenshot-icon.png){ width=500 }
<figcaption>Custom icon from an external URL</figcaption>
</figure>
## E-mail notifications
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -2735,6 +2852,22 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb
option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally
enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64.
### Matrix Gateway
The ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with
[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate
with self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since
you don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)).
In short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)),
and forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the
ntfy Android app, and passed on to the Matrix client there.
There is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the
ntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol.
!!! info
This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy.
## Public topics
Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics
that you can use to try out what [authentication and access control](#authentication) looks like.
@@ -2777,6 +2910,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
| `X-Actions` | `Actions`, `Action` | JSON array or short format of [user actions](#action-buttons) |
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |

View File

@@ -2,17 +2,143 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
<!--
## ntfy Android app v1.14.0
Released September 27, 2022
## ntfy Android app v1.14.0 (UNRELEASED)
This release adds the ability to set a custom icon to each notification, as well as a display name to subscriptions. We
also moved the action buttons in the detail view to a more logical place, fixed a bunch of bugs, and added four more
languages. Hurray!
**Features:**
* Subscriptions can now have a display name ([#313](https://github.com/binwiederhier/ntfy/issues/313), thanks to [@wunter8](https://github.com/wunter8))
* Display name for UnifiedPush subscriptions ([#355](https://github.com/binwiederhier/ntfy/issues/355), thanks to [@wunter8](https://github.com/wunter8))
* Polling is now done with `since=<id>` API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165))
* Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket)
* Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8))
* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8))
**Bugs:**
* Long-click selecting of notifications doesn't scroll to the top anymore ([#235](https://github.com/binwiederhier/ntfy/issues/235), thanks to [@wunter8](https://github.com/wunter8))
* Add attachment and click URL extras to MESSAGE_RECEIVED broadcast ([#329](https://github.com/binwiederhier/ntfy/issues/329), thanks to [@wunter8](https://github.com/wunter8))
* Accessibility: Clear/choose service URL button in base URL dropdown now has a label ([#292](https://github.com/binwiederhier/ntfy/issues/292), thanks to [@mhameed](https://github.com/mhameed) for reporting)
**Additional translations:**
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))
* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/))
* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/))
-->
Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up some Android tickets, and fixing them! You rock!
## ntfy server v1.25.1
## ntfy server v1.28.0
Released September 27, 2022
This release primarily adds icon support for the Android app, and adds a display name to subscriptions in the web app.
Aside from that, we fixed a few random bugs, most importantly the `Priority` header bug that allows the use behind
Cloudflare. We also added a ton of documentation. Most prominently, an [integrations + projects page](https://ntfy.sh/docs/integrations/).
As of now, I also have started accepting **[donations and sponsorships](https://github.com/sponsors/binwiederhier)** 💸.
I would be very humbled if you consider donating.
**Features:**
* Subscription display name for the web app ([#348](https://github.com/binwiederhier/ntfy/pull/348))
* Allow setting socket permissions via `--listen-unix-mode` ([#356](https://github.com/binwiederhier/ntfy/pull/356), thanks to [@koro666](https://github.com/koro666))
* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8))
* CLI: Allow default username/password in `client.yml` ([#372](https://github.com/binwiederhier/ntfy/pull/372), thanks to [@wunter8](https://github.com/wunter8))
* Build support for other Unix systems ([#393](https://github.com/binwiederhier/ntfy/pull/393), thanks to [@la-ninpre](https://github.com/la-ninpre))
**Bugs:**
* `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting)
* Ignore new draft HTTP `Priority` header ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting)
* Delete expired attachments based on mod time instead of DB entry to avoid races (no ticket)
* Better logging for Matrix push key errors ([#384](https://github.com/binwiederhier/ntfy/pull/384), thanks to [@christophehenry](https://github.com/christophehenry))
* Web: Switched "Pop" and "Pop Swoosh" sounds ([#352](https://github.com/binwiederhier/ntfy/issues/352), thanks to [@coma-toast](https://github.com/coma-toast) for reporting)
**Documentation:**
* Added [integrations + projects page](https://ntfy.sh/docs/integrations/) (**so many integrations, whoa!**)
* Added example for [UptimeRobot](https://ntfy.sh/docs/examples/#uptimerobot)
* Fix some PowerShell publish docs ([#345](https://github.com/binwiederhier/ntfy/pull/345), thanks to [@noahpeltier](https://github.com/noahpeltier))
* Clarified Docker install instructions ([#361](https://github.com/binwiederhier/ntfy/issues/361), thanks to [@barart](https://github.com/barart) for reporting)
* Mismatched quotation marks ([#392](https://github.com/binwiederhier/ntfy/pull/392)], thanks to [@connorlanigan](https://github.com/connorlanigan))
**Additional translations:**
* Ukranian (thanks to [@v.kopitsa](https://hosted.weblate.org/user/v.kopitsa/))
* Polish (thanks to [@Namax0r](https://hosted.weblate.org/user/Namax0r/))
## ntfy server v1.27.2
Released June 23, 2022
This release brings two new CLI options to wait for a command to finish, or for a PID to exit. It also adds more detail
to trace debug output. Aside from other bugs, it fixes a performance issue that occurred in large installations every
minute or so, due to competing stats gathering (personal installations will likely be unaffected by this).
**Features:**
* Add `cache-startup-queries` option to allow custom [SQLite performance tuning](config.md#wal-for-message-cache) (no ticket)
* ntfy CLI can now [wait for a command or PID](subscribe/cli.md#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea)
* Trace: Log entire HTTP request to simplify debugging (no ticket)
* Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3))
**Bugs:**
* Fix slow requests due to excessive locking ([#338](https://github.com/binwiederhier/ntfy/issues/338))
* Return HTTP 500 for `GET /_matrix/push/v1/notify` when `base-url` is not configured (no ticket)
* Disallow setting `upstream-base-url` to the same value as `base-url` ([#334](https://github.com/binwiederhier/ntfy/issues/334), thanks to [@oester](https://github.com/oester) for reporting)
* Fix `since=<id>` implementation for multiple topics ([#336](https://github.com/binwiederhier/ntfy/issues/336), thanks to [@karmanyaahm](https://github.com/karmanyaahm) for reporting)
* Simple parsing in `Actions` header now supports settings Android `intent=` key ([#341](https://github.com/binwiederhier/ntfy/pull/341), thanks to [@wunter8](https://github.com/wunter8))
**Deprecations:**
* The `ntfy publish --env-topic` option is deprecated as of now (see [deprecations](deprecations.md) for details)
## ntfy server v1.26.0
Released June 16, 2022
This release adds a Matrix Push Gateway directly into ntfy, to make self-hosting a Matrix server easier. The Windows
CLI is now available via Scoop, and ntfy is now natively supported in Uptime Kuma.
**Features:**
* ntfy now is a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with [UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/), [#319](https://github.com/binwiederhier/ntfy/issues/319)/[#326](https://github.com/binwiederhier/ntfy/pull/326), thanks to [@MayeulC](https://github.com/MayeulC) for reporting)
* Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu))
* [Uptime Kuma](https://github.com/louislam/uptime-kuma) now allows publishing to ntfy ([uptime-kuma#1674](https://github.com/louislam/uptime-kuma/pull/1674), thanks to [@philippdormann](https://github.com/philippdormann))
* Display ntfy version in `ntfy serve` command ([#314](https://github.com/binwiederhier/ntfy/issues/314), thanks to [@poblabs](https://github.com/poblabs))
**Bugs:**
* Web app: Show "notifications not supported" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
* Use last address in `X-Forwarded-For` header as visitor address ([#328](https://github.com/binwiederhier/ntfy/issues/328))
**Documentation**
* Added [example](examples.md) for [Uptime Kuma](https://github.com/louislam/uptime-kuma) integration ([#315](https://github.com/binwiederhier/ntfy/pull/315), thanks to [@philippdormann](https://github.com/philippdormann))
* Fix Docker install instructions ([#320](https://github.com/binwiederhier/ntfy/issues/320), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
* Add clarifying comments to base-url ([#322](https://github.com/binwiederhier/ntfy/issues/322), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
* Update FAQ for iOS app ([#321](https://github.com/binwiederhier/ntfy/issues/321), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
## ntfy iOS app v1.2
Released June 16, 2022
This release adds support for authentication/authorization for self-hosted servers. It also allows you to
set your server as the default server for new topics.
**Features:**
* Support for auth and user management ([#277](https://github.com/binwiederhier/ntfy/issues/277))
* Ability to add default server ([#295](https://github.com/binwiederhier/ntfy/issues/295))
**Bugs:**
* Add validation for selfhosted server URL ([#290](https://github.com/binwiederhier/ntfy/issues/290))
## ntfy server v1.25.2
Released June 2, 2022
This release adds the ability to set a log level to facilitate easier debugging of live systems. It also solves a
@@ -25,7 +151,6 @@ more translations: Chinese/Simplified and Dutch.
**Features:**
* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284))
* Add `tzdata` to Docker image to allow overriding the timezone with `TZ` ([#307](https://github.com/binwiederhier/ntfy/pull/307), thanks to [@ksurl](https://github.com/ksurl))
**Bugs**:

View File

@@ -60,7 +60,8 @@ figure video {
}
.screenshots img {
height: 230px;
max-height: 230px;
max-width: 300px;
margin: 3px;
border-radius: 5px;
filter: drop-shadow(2px 2px 2px #ddd);

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/static/img/uptimekuma-ios-down.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/static/img/uptimekuma-ios-test.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
docs/static/img/uptimekuma-ios-up.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
docs/static/img/uptimekuma-settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
docs/static/img/uptimekuma-setup.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
docs/static/img/uptimerobot-setup.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
docs/static/img/uptimerobot-test.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -87,7 +87,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
### Subscribe as SSE stream
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html).
easy to use. Here's what it looks like. You may also want to check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-eventsource).
=== "Command line (curl)"
```

View File

@@ -56,6 +56,71 @@ quick ones:
ntfy pub mywebhook
```
### Attaching a local file
You can easily upload and attach a local file to a notification:
```
$ ntfy pub --file README.md mytopic | jq .
{
"id": "meIlClVLABJQ",
"time": 1655825460,
"event": "message",
"topic": "mytopic",
"message": "You received a file: README.md",
"attachment": {
"name": "README.md",
"type": "text/plain; charset=utf-8",
"size": 2892,
"expires": 1655836260,
"url": "https://ntfy.sh/file/meIlClVLABJQ.txt"
}
}
```
### Wait for PID/command
If you have a long-running command and want to **publish a notification when the command completes**,
you may wrap it with `ntfy publish --wait-cmd` (aliases: `--cmd`, `--done`). Or, if you forgot to wrap it, and the
command is already running, you can wait for the process to complete with `ntfy publish --wait-pid` (alias: `--pid`).
Run a command and wait for it to complete (here: `rsync ...`):
```
$ ntfy pub --wait-cmd mytopic rsync -av ./ root@example.com:/backups/ | jq .
{
"id": "Re0rWXZQM8WB",
"time": 1655825624,
"event": "message",
"topic": "mytopic",
"message": "Command succeeded after 56.553s: rsync -av ./ root@example.com:/backups/"
}
```
Or, if you already started the long-running process and want to wait for it using its process ID (PID), you can do this:
=== "Using a PID directly"
```
$ ntfy pub --wait-pid 8458 mytopic | jq .
{
"id": "orM6hJKNYkWb",
"time": 1655825827,
"event": "message",
"topic": "mytopic",
"message": "Process with PID 8458 exited after 2.003s"
}
```
=== "Using a `pidof`"
```
$ ntfy pub --wait-pid $(pidof rsync) mytopic | jq .
{
"id": "orM6hJKNYkWb",
"time": 1655825827,
"event": "message",
"topic": "mytopic",
"message": "Process with PID 8458 exited after 2.003s"
}
```
## Subscribe to topics
You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command
will either print or execute a command for every arriving message. There are a few different ways
@@ -189,6 +254,14 @@ I hope this shows how powerful this command is. Here's a short video that demons
<figcaption>Execute all the things</figcaption>
</figure>
If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the
`client.yml`. If a subscription does not specify a username/password to use or does not have a command, the defaults will be used, otherwise, the subscription settings will
override the defaults.
!!! warning
Because the `default-user` and `default-password` will be sent for each topic that does not have its own username/password (even if the topic does not require authentication),
be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
### Using the systemd service
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
to subscribe to multiple topics just like in the example above. The service is automatically installed (but not started)

View File

@@ -180,21 +180,27 @@ notification popups:
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
| Extra name | Type | Example | Description |
|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------|
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
| `encoding` | *String* | - | Message encoding (empty or "base64") |
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
| Extra name | Type | Example | Description |
|----------------------|------------------------------|------------------------------------------|------------------------------------------------------------------------------------|
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
| `encoding` | *String* | - | Message encoding (empty or "base64") |
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
| `click` | *String* | `https://google.com` | [Click action](../publish.md#click-action) URL, or empty if not set |
| `attachment_name` | *String* | `attachment.jpg` | Filename of the attachment; may be empty if not set |
| `attachment_type` | *String* | `image/jpeg` | Mime type of the attachment; may be empty if not set |
| `attachment_size` | *Long* | `9923111` | Size in bytes of the attachment; may be zero if not set |
| `attachment_expires` | *Long* | `1655514244` | Expiry date as Unix timestamp of the attachment URL; may be zero if not set |
| `attachment_url` | *String* | `https://ntfy.sh/file/afUbjadfl7ErP.jpg` | URL of the attachment; may be empty if not set |
#### Send messages using intents
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)

52
go.mod
View File

@@ -4,22 +4,22 @@ go 1.17
require (
cloud.google.com/go/firestore v1.6.1 // indirect
cloud.google.com/go/storage v1.22.1 // indirect
github.com/BurntSushi/toml v1.1.0 // indirect
cloud.google.com/go/storage v1.27.0 // indirect
github.com/BurntSushi/toml v1.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/gabriel-vasile/mimetype v1.4.0
github.com/gabriel-vasile/mimetype v1.4.1
github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.13
github.com/mattn/go-sqlite3 v1.14.15
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.8.1
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
golang.org/x/time v0.0.0-20220411224347-583f2d630306
google.golang.org/api v0.82.0
github.com/urfave/cli/v2 v2.16.3
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 // indirect
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7
golang.org/x/term v0.0.0-20220919170432-7a66f970e087
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af
google.golang.org/api v0.97.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -28,30 +28,30 @@ require github.com/pkg/errors v0.9.1 // indirect
require firebase.google.com/go/v4 v4.8.0
require (
cloud.google.com/go v0.102.0 // indirect
cloud.google.com/go/compute v1.6.1 // indirect
cloud.google.com/go/iam v0.3.0 // indirect
cloud.google.com/go v0.104.0 // indirect
cloud.google.com/go/compute v1.10.0 // indirect
cloud.google.com/go/iam v0.4.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
github.com/googleapis/go-type-adapters v1.0.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
github.com/googleapis/gax-go/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220531201128-c960675eff93 // indirect
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
golang.org/x/net v0.0.0-20220927155233-aa73b2587036 // indirect
golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.1 // indirect
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect
google.golang.org/grpc v1.47.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
google.golang.org/appengine/v2 v2.0.2 // indirect
google.golang.org/genproto v0.0.0-20220927151529-dcaddaf36704 // indirect
google.golang.org/grpc v1.49.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

197
go.sum
View File

@@ -28,40 +28,106 @@ cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Ud
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU=
cloud.google.com/go v0.104.0 h1:gSmWO7DY1vOm0MVU6DNXM11BWHHsTUmsC5cv1fuW5X8=
cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA=
cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw=
cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI=
cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4=
cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ=
cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o=
cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY=
cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA=
cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY=
cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM=
cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY=
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc=
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
cloud.google.com/go/compute v1.10.0 h1:aoLIYaA1fX3ywihqpBk2APQKOo20nXsp1GEZQbx5Jk4=
cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU=
cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I=
cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0=
cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs=
cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM=
cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo=
cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I=
cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo=
cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4=
cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU=
cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y=
cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk=
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk=
cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM=
cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o=
cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0=
cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc=
cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
cloud.google.com/go/iam v0.4.0 h1:YBYU00SCDzZJdHqVc4I5d6lsklcYIjQZa1YmEz4jlSE=
cloud.google.com/go/iam v0.4.0/go.mod h1:cbaZxyScUhxl7ZAkNWiALgihfP75wS/fUsVNaa1r3vA=
cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic=
cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8=
cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4=
cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE=
cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY=
cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA=
cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ=
cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY=
cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs=
cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E=
cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0=
cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4=
cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o=
cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg=
cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg=
cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y=
cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4=
cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s=
cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA=
cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4=
cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0=
cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU=
cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs=
cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg=
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
cloud.google.com/go/storage v1.27.0 h1:YOO045NZI9RKfCj1c5A/ZtuuENUc8OAW+gHdGnDgyMQ=
cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw=
cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU=
cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0=
cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo=
cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE=
cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go/v4 v4.8.0 h1:ooJqjFEh1G6DQ5+wyb/RAXAgku0E2RzJeH6WauSpWSo=
firebase.google.com/go/v4 v4.8.0/go.mod h1:y+j6xX7BgBco/XaN+YExIBVm6pzvYutheDV3nprvbWc=
@@ -69,8 +135,9 @@ github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QK
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@@ -90,15 +157,14 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/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/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -111,8 +177,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -166,8 +232,9 @@ github.com/google/go-cmp v0.5.4/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.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -192,15 +259,18 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw=
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk=
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
github.com/googleapis/gax-go/v2 v2.5.1 h1:kBRZU0PSuI7PspsSb/ChWoVResUcwNVIdpB049pKTiw=
github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -217,8 +287,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -239,8 +309,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4=
github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
github.com/urfave/cli/v2 v2.16.3 h1:gHoFIwpPjoyIMbJp/VFd+/vuD0dAgFK4B6DpEMFJfQk=
github.com/urfave/cli/v2 v2.16.3/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI=
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/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -262,8 +332,8 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -334,16 +404,19 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA=
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/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-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220927155233-aa73b2587036 h1:GDWXwjBkdo4XMin5T4iul98eH4BfGOR7TucJ057FxjY=
golang.org/x/net v0.0.0-20220927155233-aa73b2587036/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -364,8 +437,11 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 h1:zwrSfklXn0gxyLRX/aR+q6cgHbV/ItVyzbPlbA+dkAw=
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 h1:lxqLZaMad/dJHMFZH0NiNpiEZI/nhgWhe4wgzpE+MuA=
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -377,9 +453,9 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 h1:ZrnxWX62AgTKOSagEqxvb3ffipvEDX2pl7E1TdqLqIc=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -439,12 +515,17 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25 h1:nwzwVf0l2Y/lkov/+IYgMMbFyI+QypZDds9RxlSmsFQ=
golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -458,8 +539,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af h1:Yx9k8YCG3dvF87UAn2tu2HQLf2dt/eR1bXxpLMWeH+Y=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -516,8 +597,10 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
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=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -559,10 +642,17 @@ google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc
google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw=
google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os=
google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI=
google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
google.golang.org/api v0.97.0 h1:x/vEL1XDF/2V4xzdNgFPaKHluRESo2aTsL7QzHnBtGQ=
google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
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.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -571,8 +661,9 @@ google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine/v2 v2.0.1 h1:jTGfiRmR5qoInpT3CXJ72GJEB4owDGEKN+xRDA6ekBY=
google.golang.org/appengine/v2 v2.0.1/go.mod h1:XgltgQxPOF3ShivrVrZyfvYCx8Dunh73bKjUuXUZb8Q=
google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk=
google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -654,12 +745,30 @@ google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE=
google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw=
google.golang.org/genproto v0.0.0-20220927151529-dcaddaf36704 h1:H1AcWFV69NFCMeBJ8nVLtv8uHZZ5Ozcgoq012hHEFuU=
google.golang.org/genproto v0.0.0-20220927151529-dcaddaf36704/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -690,8 +799,10 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
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=
@@ -706,8 +817,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -716,8 +828,9 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -85,6 +85,7 @@ nav:
- "Other things":
- "FAQs": faq.md
- "Examples": examples.md
- "Integrations + projects": integrations.md
- "Release notes": releases.md
- "Deprecation notices": deprecations.md
- "Emojis 🥳 🎉": emojis.md

View File

@@ -87,7 +87,8 @@ func parseActionsFromJSON(s string) ([]*action, error) {
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
//
// It can parse an actions string like this:
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
//
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
//
// It works by advancing the position ("pos") through the input string ("input").
//
@@ -96,10 +97,11 @@ func parseActionsFromJSON(s string) ([]*action, error) {
// though it does not use state functions at all.
//
// Other resources:
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
//
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
func parseActionsFromSimple(s string) ([]*action, error) {
if !utf8.ValidString(s) {
return nil, errors.New("invalid utf-8 string")
@@ -186,6 +188,8 @@ func populateAction(newAction *action, section int, key, value string) error {
newAction.Method = value
case "body":
newAction.Body = value
case "intent":
newAction.Intent = value
default:
return fmt.Errorf("key '%s' unknown", key)
}

View File

@@ -52,6 +52,14 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "some command", actions[0].Extras["command"])
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
// Broadcast action with intent
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
require.Equal(t, "Do a thing", actions[0].Label)
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
// Headers with dashes
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
require.Nil(t, err)

View File

@@ -1,6 +1,7 @@
package server
import (
"io/fs"
"time"
)
@@ -52,11 +53,13 @@ type Config struct {
ListenHTTP string
ListenHTTPS string
ListenUnix string
ListenUnixMode fs.FileMode
KeyFile string
CertFile string
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
CacheStartupQueries string
AuthFile string
AuthDefaultRead bool
AuthDefaultWrite bool
@@ -94,6 +97,7 @@ type Config struct {
VisitorEmailLimitReplenish time.Duration
BehindProxy bool
EnableWeb bool
Version string // injected by App
}
// NewConfig instantiates a default new server config
@@ -103,6 +107,7 @@ func NewConfig() *Config {
ListenHTTP: DefaultListenHTTP,
ListenHTTPS: "",
ListenUnix: "",
ListenUnixMode: 0,
KeyFile: "",
CertFile: "",
FirebaseKeyFile: "",
@@ -135,5 +140,6 @@ func NewConfig() *Config {
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
BehindProxy: false,
EnableWeb: true,
Version: "",
}
}

View File

@@ -50,10 +50,14 @@ var (
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPEntityTooLargeMatrixRequestTooLarge = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, 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"}
@@ -61,4 +65,5 @@ var (
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
)

View File

@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ntfy.sh: EventSource Example</title>
<meta name="robots" content="noindex, nofollow" />
<style>
body { font-size: 1.2em; line-height: 130%; }
#events { font-family: monospace; }
</style>
</head>
<body>
<h1>ntfy.sh: EventSource Example</h1>
<p>
This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
<a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>.<br/>
This example doesn't need a server. You can just save the HTML page and run it from anywhere.
</p>
<button id="publishButton">Send test notification</button>
<p><b>Log:</b></p>
<div id="events"></div>
<script type="text/javascript">
const publishURL = `https://ntfy.sh/example`;
const subscribeURL = `https://ntfy.sh/example/sse`;
const events = document.getElementById('events');
const eventSource = new EventSource(subscribeURL);
// Publish button
document.getElementById("publishButton").onclick = () => {
fetch(publishURL, {
method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
body: `It is ${new Date().toString()}. This is a test.`
})
};
// Incoming events
eventSource.onopen = () => {
let event = document.createElement('div');
event.innerHTML = `EventSource connected to ${subscribeURL}`;
events.appendChild(event);
};
eventSource.onerror = (e) => {
let event = document.createElement('div');
event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
events.appendChild(event);
};
eventSource.onmessage = (e) => {
let event = document.createElement('div');
event.innerHTML = e.data;
events.appendChild(event);
};
</script>
</body>
</html>

View File

@@ -2,16 +2,18 @@ package server
import (
"errors"
"fmt"
"heckel.io/ntfy/util"
"io"
"os"
"path/filepath"
"regexp"
"sync"
"time"
)
var (
fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength))
errInvalidFileID = errors.New("invalid file ID")
errFileExists = errors.New("file exists")
)
@@ -88,6 +90,25 @@ func (c *fileCache) Remove(ids ...string) error {
return nil
}
// Expired returns a list of file IDs for expired files
func (c *fileCache) Expired(olderThan time.Time) ([]string, error) {
entries, err := os.ReadDir(c.dir)
if err != nil {
return nil, err
}
var ids []string
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(olderThan) && fileIDRegex.MatchString(e.Name()) {
ids = append(ids, e.Name())
}
}
return ids, nil
}
func (c *fileCache) Size() int64 {
c.mu.Lock()
defer c.mu.Unlock()

View File

@@ -8,6 +8,7 @@ import (
"os"
"strings"
"testing"
"time"
)
var (
@@ -16,10 +17,10 @@ var (
func TestFileCache_Write_Success(t *testing.T) {
dir, c := newTestFileCache(t)
size, err := c.Write("abc", strings.NewReader("normal file"), util.NewFixedLimiter(999))
size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999))
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, "normal file", readFile(t, dir+"/abc"))
require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl"))
require.Equal(t, int64(11), c.Size())
require.Equal(t, int64(10229), c.Remaining())
}
@@ -27,18 +28,18 @@ func TestFileCache_Write_Success(t *testing.T) {
func TestFileCache_Write_Remove_Success(t *testing.T) {
dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)
for i := 0; i < 10; i++ { // 10x999 = 9990
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(make([]byte, 999)))
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999)))
require.Nil(t, err)
require.Equal(t, int64(999), size)
}
require.Equal(t, int64(9990), c.Size())
require.Equal(t, int64(250), c.Remaining())
require.FileExists(t, dir+"/abc1")
require.FileExists(t, dir+"/abc5")
require.FileExists(t, dir+"/abcdefghijk1")
require.FileExists(t, dir+"/abcdefghijk5")
require.Nil(t, c.Remove("abc1", "abc5"))
require.NoFileExists(t, dir+"/abc1")
require.NoFileExists(t, dir+"/abc5")
require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5"))
require.NoFileExists(t, dir+"/abcdefghijk1")
require.NoFileExists(t, dir+"/abcdefghijk5")
require.Equal(t, int64(7992), c.Size())
require.Equal(t, int64(2248), c.Remaining())
}
@@ -46,27 +47,50 @@ func TestFileCache_Write_Remove_Success(t *testing.T) {
func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
dir, c := newTestFileCache(t)
for i := 0; i < 10; i++ {
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(oneKilobyteArray))
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray))
require.Nil(t, err)
require.Equal(t, int64(1024), size)
}
_, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray))
_, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray))
require.Equal(t, util.ErrLimitReached, err)
require.NoFileExists(t, dir+"/abc11")
require.NoFileExists(t, dir+"/abcdefghijkX")
}
func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) {
dir, c := newTestFileCache(t)
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1025)))
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1025)))
require.Equal(t, util.ErrLimitReached, err)
require.NoFileExists(t, dir+"/abc")
require.NoFileExists(t, dir+"/abcdefghijkl")
}
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
dir, c := newTestFileCache(t)
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
require.Equal(t, util.ErrLimitReached, err)
require.NoFileExists(t, dir+"/abc")
require.NoFileExists(t, dir+"/abcdefghijkl")
}
func TestFileCache_RemoveExpired(t *testing.T) {
dir, c := newTestFileCache(t)
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)))
require.Nil(t, err)
_, err = c.Write("notdeleted12", bytes.NewReader(make([]byte, 1001)))
require.Nil(t, err)
modTime := time.Now().Add(-1 * 4 * time.Hour)
require.Nil(t, os.Chtimes(dir+"/abcdefghijkl", modTime, modTime))
olderThan := time.Now().Add(-1 * 3 * time.Hour)
ids, err := c.Expired(olderThan)
require.Nil(t, err)
require.Equal(t, []string{"abcdefghijkl"}, ids)
require.Nil(t, c.Remove(ids...))
require.NoFileExists(t, dir+"/abcdefghijkl")
require.FileExists(t, dir+"/notdeleted12")
ids, err = c.Expired(olderThan)
require.Nil(t, err)
require.Empty(t, ids)
}
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {

View File

@@ -30,6 +30,7 @@ const (
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
@@ -45,52 +46,51 @@ const (
COMMIT;
`
insertMessageQuery = `
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesSinceTimeQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time, id
`
selectMessagesSinceIDQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
FROM messages
WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
FROM messages
WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id
`
selectMessagesDueQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
FROM messages
WHERE time <= ? AND published = 0
ORDER BY time, id
`
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?`
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
)
// Schema management queries
const (
currentSchemaVersion = 7
currentSchemaVersion = 8
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -178,6 +178,11 @@ const (
migrate6To7AlterMessagesTableQuery = `
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
`
// 7 -> 8
migrate7To8AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
`
)
type messageCache struct {
@@ -186,12 +191,12 @@ type messageCache struct {
}
// newSqliteCache creates a SQLite file-backed cache
func newSqliteCache(filename string, nop bool) (*messageCache, error) {
func newSqliteCache(filename, startupQueries string, nop bool) (*messageCache, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupCacheDB(db); err != nil {
if err := setupCacheDB(db, startupQueries); err != nil {
return nil, err
}
return &messageCache{
@@ -202,13 +207,13 @@ func newSqliteCache(filename string, nop bool) (*messageCache, error) {
// newMemCache creates an in-memory cache
func newMemCache() (*messageCache, error) {
return newSqliteCache(createMemoryFilename(), false)
return newSqliteCache(createMemoryFilename(), "", false)
}
// newNopCache creates an in-memory cache that discards all messages;
// it is always empty and can be used if caching is entirely disabled
func newNopCache() (*messageCache, error) {
return newSqliteCache(createMemoryFilename(), true)
return newSqliteCache(createMemoryFilename(), "", true)
}
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
@@ -222,52 +227,67 @@ func createMemoryFilename() string {
}
func (c *messageCache) AddMessage(m *message) error {
if m.Event != messageEvent {
return errUnexpectedMessageType
}
return c.addMessages([]*message{m})
}
func (c *messageCache) addMessages(ms []*message) error {
if c.nop {
return nil
}
published := m.Time <= time.Now().Unix()
tags := strings.Join(m.Tags, ",")
var attachmentName, attachmentType, attachmentURL string
var attachmentSize, attachmentExpires int64
if m.Attachment != nil {
attachmentName = m.Attachment.Name
attachmentType = m.Attachment.Type
attachmentSize = m.Attachment.Size
attachmentExpires = m.Attachment.Expires
attachmentURL = m.Attachment.URL
tx, err := c.db.Begin()
if err != nil {
return err
}
var actionsStr string
if len(m.Actions) > 0 {
actionsBytes, err := json.Marshal(m.Actions)
defer tx.Rollback()
for _, m := range ms {
if m.Event != messageEvent {
return errUnexpectedMessageType
}
published := m.Time <= time.Now().Unix()
tags := strings.Join(m.Tags, ",")
var attachmentName, attachmentType, attachmentURL string
var attachmentSize, attachmentExpires int64
if m.Attachment != nil {
attachmentName = m.Attachment.Name
attachmentType = m.Attachment.Type
attachmentSize = m.Attachment.Size
attachmentExpires = m.Attachment.Expires
attachmentURL = m.Attachment.URL
}
var actionsStr string
if len(m.Actions) > 0 {
actionsBytes, err := json.Marshal(m.Actions)
if err != nil {
return err
}
actionsStr = string(actionsBytes)
}
_, err := tx.Exec(
insertMessageQuery,
m.ID,
m.Time,
m.Topic,
m.Message,
m.Title,
m.Priority,
tags,
m.Click,
m.Icon,
actionsStr,
attachmentName,
attachmentType,
attachmentSize,
attachmentExpires,
attachmentURL,
m.Sender,
m.Encoding,
published,
)
if err != nil {
return err
}
actionsStr = string(actionsBytes)
}
_, err := c.db.Exec(
insertMessageQuery,
m.ID,
m.Time,
m.Topic,
m.Message,
m.Title,
m.Priority,
tags,
m.Click,
actionsStr,
attachmentName,
attachmentType,
attachmentSize,
attachmentExpires,
attachmentURL,
m.Sender,
m.Encoding,
published,
)
return err
return tx.Commit()
}
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
@@ -294,7 +314,7 @@ func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, schedu
}
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
idrows, err := c.db.Query(selectRowIDFromMessageID, topic, since.ID())
idrows, err := c.db.Query(selectRowIDFromMessageID, since.ID())
if err != nil {
return nil, err
}
@@ -332,22 +352,24 @@ func (c *messageCache) MarkPublished(m *message) error {
return err
}
func (c *messageCache) MessageCount(topic string) (int, error) {
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
func (c *messageCache) MessageCounts() (map[string]int, error) {
rows, err := c.db.Query(selectMessageCountPerTopicQuery)
if err != nil {
return 0, err
return nil, err
}
defer rows.Close()
var topic string
var count int
if !rows.Next() {
return 0, errors.New("no rows found")
counts := make(map[string]int)
for rows.Next() {
if err := rows.Scan(&topic, &count); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
counts[topic] = count
}
if err := rows.Scan(&count); err != nil {
return 0, err
} else if err := rows.Err(); err != nil {
return 0, err
}
return count, nil
return counts, nil
}
func (c *messageCache) Topics() (map[string]*topic, error) {
@@ -393,33 +415,13 @@ func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) {
return size, nil
}
func (c *messageCache) AttachmentsExpired() ([]string, error) {
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ids, nil
}
func readMessages(rows *sql.Rows) ([]*message, error) {
defer rows.Close()
messages := make([]*message, 0)
for rows.Next() {
var timestamp, attachmentSize, attachmentExpires int64
var priority int
var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string
err := rows.Scan(
&id,
&timestamp,
@@ -429,6 +431,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
&priority,
&tagsStr,
&click,
&icon,
&actionsStr,
&attachmentName,
&attachmentType,
@@ -471,6 +474,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
Priority: priority,
Tags: tags,
Click: click,
Icon: icon,
Actions: actions,
Attachment: att,
Sender: sender,
@@ -483,7 +487,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
return messages, nil
}
func setupCacheDB(db *sql.DB) error {
func setupCacheDB(db *sql.DB, startupQueries string) error {
// Run startup queries
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
}
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(selectMessagesCountQuery)
if err != nil {
@@ -522,6 +533,8 @@ func setupCacheDB(db *sql.DB) error {
return migrateFrom5(db)
} else if schemaVersion == 6 {
return migrateFrom6(db)
} else if schemaVersion == 7 {
return migrateFrom7(db)
}
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
}
@@ -616,5 +629,16 @@ func migrateFrom6(db *sql.DB) error {
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
return err
}
return migrateFrom7(db)
}
func migrateFrom7(db *sql.DB) error {
log.Info("Migrating cache database schema: from 7 to 8")
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
return err
}
return nil // Update this when a new version is added
}

View File

@@ -34,9 +34,9 @@ func testCacheMessages(t *testing.T, c *messageCache) {
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
// mytopic: count
count, err := c.MessageCount("mytopic")
counts, err := c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 2, count)
require.Equal(t, 2, counts["mytopic"])
// mytopic: since all
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
@@ -66,18 +66,18 @@ func testCacheMessages(t *testing.T, c *messageCache) {
require.Equal(t, "my other message", messages[0].Message)
// example: count
count, err = c.MessageCount("example")
counts, err = c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 1, count)
require.Equal(t, 1, counts["example"])
// example: since all
messages, _ = c.Messages("example", sinceAllMessages, false)
require.Equal(t, "my example message", messages[0].Message)
// non-existing: count
count, err = c.MessageCount("doesnotexist")
counts, err = c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 0, count)
require.Equal(t, 0, counts["doesnotexist"])
// non-existing: since all
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
@@ -255,13 +255,13 @@ func testCachePrune(t *testing.T, c *messageCache) {
require.Nil(t, c.AddMessage(m3))
require.Nil(t, c.Prune(time.Unix(2, 0)))
count, err := c.MessageCount("mytopic")
counts, err := c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 1, count)
require.Equal(t, 1, counts["mytopic"])
count, err = c.MessageCount("another_topic")
counts, err = c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 0, count)
require.Equal(t, 0, counts["another_topic"])
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
@@ -344,10 +344,6 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
size, err = c.AttachmentBytesUsed("5.6.7.8")
require.Nil(t, err)
require.Equal(t, int64(0), size)
ids, err := c.AttachmentsExpired()
require.Nil(t, err)
require.Equal(t, []string{"m1"}, ids)
}
func TestSqliteCache_Migration_From0(t *testing.T) {
@@ -378,7 +374,7 @@ func TestSqliteCache_Migration_From0(t *testing.T) {
require.Nil(t, db.Close())
// Create cache to trigger migration
c := newSqliteTestCacheFromFile(t, filename)
c := newSqliteTestCacheFromFile(t, filename, "")
checkSchemaVersion(t, c.db)
messages, err := c.Messages("mytopic", sinceAllMessages, false)
@@ -424,7 +420,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
require.Nil(t, db.Close())
// Create cache to trigger migration
c := newSqliteTestCacheFromFile(t, filename)
c := newSqliteTestCacheFromFile(t, filename, "")
checkSchemaVersion(t, c.db)
// Add delayed message
@@ -443,6 +439,37 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
require.Equal(t, 11, len(messages))
}
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
filename := newSqliteTestCacheFile(t)
startupQueries := `pragma journal_mode = WAL;
pragma synchronous = normal;
pragma temp_store = memory;`
db, err := newSqliteCache(filename, startupQueries, false)
require.Nil(t, err)
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
require.FileExists(t, filename)
require.FileExists(t, filename+"-wal")
require.FileExists(t, filename+"-shm")
}
func TestSqliteCache_StartupQueries_None(t *testing.T) {
filename := newSqliteTestCacheFile(t)
startupQueries := ""
db, err := newSqliteCache(filename, startupQueries, false)
require.Nil(t, err)
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
require.FileExists(t, filename)
require.NoFileExists(t, filename+"-wal")
require.NoFileExists(t, filename+"-shm")
}
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
filename := newSqliteTestCacheFile(t)
startupQueries := `xx error`
_, err := newSqliteCache(filename, startupQueries, false)
require.Error(t, err)
}
func checkSchemaVersion(t *testing.T, db *sql.DB) {
rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err)
@@ -468,7 +495,7 @@ func TestMemCache_NopCache(t *testing.T) {
}
func newSqliteTestCache(t *testing.T) *messageCache {
c, err := newSqliteCache(newSqliteTestCacheFile(t), false)
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", false)
if err != nil {
t.Fatal(err)
}
@@ -479,8 +506,8 @@ func newSqliteTestCacheFile(t *testing.T) string {
return filepath.Join(t.TempDir(), "cache.db")
}
func newSqliteTestCacheFromFile(t *testing.T, filename string) *messageCache {
c, err := newSqliteCache(filename, false)
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
c, err := newSqliteCache(filename, startupQueries, false)
if err != nil {
t.Fatal(err)
}

View File

@@ -8,7 +8,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"io"
"net"
"net/http"
@@ -17,12 +16,15 @@ import (
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"heckel.io/ntfy/log"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"golang.org/x/sync/errgroup"
@@ -67,14 +69,12 @@ var (
webConfigPath = "/config.js"
userStatsPath = "/user/stats"
matrixPushPath = "/_matrix/push/v1/notify"
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
attachURLRegex = regexp.MustCompile(`^https?://`)
//go:embed "example.html"
exampleSource string
urlRegex = regexp.MustCompile(`^https?://`)
//go:embed site
webFs embed.FS
@@ -158,7 +158,7 @@ func createMessageCache(conf *Config) (*messageCache, error) {
if conf.CacheDuration == 0 {
return newNopCache()
} else if conf.CacheFile != "" {
return newSqliteCache(conf.CacheFile, false)
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, false)
}
return newMemCache()
}
@@ -179,7 +179,7 @@ func (s *Server) Run() error {
if s.config.SMTPServerListen != "" {
listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
}
log.Info("Listening on%s, log level is %s", listenStr, log.CurrentLevel().String())
log.Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
mux := http.NewServeMux()
mux.HandleFunc("/", s.handle)
errChan := make(chan error)
@@ -204,9 +204,18 @@ func (s *Server) Run() error {
os.Remove(s.config.ListenUnix)
s.unixListener, err = net.Listen("unix", s.config.ListenUnix)
if err != nil {
s.mu.Unlock()
errChan <- err
return
}
defer s.unixListener.Close()
if s.config.ListenUnixMode > 0 {
if err := os.Chmod(s.config.ListenUnix, s.config.ListenUnixMode); err != nil {
s.mu.Unlock()
errChan <- err
return
}
}
s.mu.Unlock()
httpServer := &http.Server{Handler: mux}
errChan <- httpServer.Serve(s.unixListener)
@@ -247,6 +256,9 @@ func (s *Server) Stop() {
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
v := s.visitor(r)
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
if log.IsTrace() {
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
}
if err := s.handleInternal(w, r, v); err != nil {
if websocket.IsWebSocketUpgrade(r) {
isNormalError := strings.Contains(err.Error(), "i/o timeout")
@@ -257,6 +269,10 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
}
return // Do not attempt to write to upgraded connection
}
if matrixErr, ok := err.(*errMatrix); ok {
writeMatrixError(w, r, v, matrixErr)
return
}
httpErr, ok := err.(*errHTTP)
if !ok {
httpErr = errHTTPInternalError
@@ -277,24 +293,26 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
if r.Method == http.MethodGet && r.URL.Path == "/" {
return s.ensureWebEnabled(s.handleHome)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
return s.ensureWebEnabled(s.handleExample)(w, r, v)
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
} else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
return s.limitRequests(s.handleFile)(w, r, v)
} else if r.Method == http.MethodOptions {
return s.ensureWebEnabled(s.handleOptions)(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
return s.limitRequests(s.transformMatrixJSON(s.authWrite(s.handlePublishMatrix)))(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
@@ -347,11 +365,6 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
return err
}
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
_, err := io.WriteString(w, exampleSource)
return err
}
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
appRoot := "/"
if !s.config.WebRootIsApp {
@@ -405,39 +418,51 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
if err != nil {
return errHTTPNotFound
}
if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
return errHTTPTooManyRequestsAttachmentBandwidthLimit
if r.Method == http.MethodGet {
if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
return errHTTPTooManyRequestsAttachmentBandwidthLimit
}
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
f, err := os.Open(file)
if err != nil {
if r.Method == http.MethodGet {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f)
return err
}
defer f.Close()
_, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f)
return err
return nil
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
if s.config.BaseURL == "" {
return errHTTPInternalErrorMissingBaseURL
}
return writeMatrixDiscoveryResponse(w)
}
func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) {
t, err := s.topicFromPath(r.URL.Path)
if err != nil {
return err
return nil, err
}
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return err
return nil, err
}
m := newDefaultMessage(t.ID, "")
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
if err != nil {
return err
return nil, err
}
if m.PollID != "" {
m = newPollRequestMessage(t.ID, m.PollID)
}
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
return err
return nil, err
}
if m.Message == "" {
m.Message = emptyMessageBody
@@ -450,7 +475,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
}
if !delayed {
if err := t.Publish(v, m); err != nil {
return err
return nil, err
}
if s.firebaseClient != nil && firebase {
go s.sendToFirebase(v, m)
@@ -466,20 +491,36 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
}
if cache {
if err := s.messageCache.AddMessage(m); err != nil {
return err
return nil, err
}
}
s.mu.Lock()
s.messages++
s.mu.Unlock()
return m, nil
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
m, err := s.handlePublishWithoutResponse(r, v)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(m); err != nil {
return err
}
s.mu.Lock()
s.messages++
s.mu.Unlock()
return nil
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
_, err := s.handlePublishWithoutResponse(r, v)
if err != nil {
return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err}
}
return writeMatrixSuccess(w)
}
func (s *Server) sendToFirebase(v *visitor, m *message) {
log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
if err := s.firebaseClient.Send(v, m); err != nil {
@@ -527,6 +568,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t")
m.Click = readParam(r, "x-click", "click")
icon := readParam(r, "x-icon", "icon")
filename := readParam(r, "x-filename", "filename", "file", "f")
attach := readParam(r, "x-attach", "attach", "a")
if attach != "" || filename != "" {
@@ -536,7 +578,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
m.Attachment.Name = filename
}
if attach != "" {
if !attachURLRegex.MatchString(attach) {
if !urlRegex.MatchString(attach) {
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
@@ -553,6 +595,12 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
m.Attachment.Name = "attachment"
}
}
if icon != "" {
if !urlRegex.MatchString(icon) {
return false, false, "", false, errHTTPBadRequestIconURLInvalid
}
m.Icon = icon
}
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if email != "" {
if err := v.EmailAllowed(); err != nil {
@@ -619,18 +667,18 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
//
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
// If a message is flagged as poll request, the body does not matter and is discarded
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
// If body is binary, encode as base64, if not do not encode
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
// Body must be a message, because we attached an external URL
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
// Body must be attachment, because we passed a filename
// 5. curl -T file.txt ntfy.sh/mytopic
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 6. curl -T file.txt ntfy.sh/mytopic
// If file.txt is > message limit, treat it as an attachment
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
// If a message is flagged as poll request, the body does not matter and is discarded
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
// If body is binary, encode as base64, if not do not encode
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
// Body must be a message, because we attached an external URL
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
// Body must be attachment, because we passed a filename
// 5. curl -T file.txt ntfy.sh/mytopic
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 6. curl -T file.txt ntfy.sh/mytopic
// If file.txt is > message limit, treat it as an attachment
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
if m.Event == pollRequestEvent { // Case 1
return s.handleBodyDiscard(body)
@@ -766,6 +814,13 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
return err
}
var wlock sync.Mutex
defer func() {
// Hack: This is the fix for a horrible data race that I have not been able to figure out in quite some time.
// It appears to be happening when the Go HTTP code reads from the socket when closing the request (i.e. AFTER
// this function returns), and causes a data race with the ResponseWriter. Locking wlock here silences the
// data race detector. See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889.
wlock.TryLock()
}()
sub := func(v *visitor, msg *message) error {
if !filters.Pass(msg) {
return nil
@@ -941,19 +996,26 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
return
}
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
// marker, returning only messages that are newer than the marker.
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
if since.IsNone() {
return nil
}
messages := make([]*message, 0)
for _, t := range topics {
messages, err := s.messageCache.Messages(t.ID, since, scheduled)
topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled)
if err != nil {
return err
}
for _, m := range messages {
if err := sub(v, m); err != nil {
return err
}
messages = append(messages, topicMessages...)
}
sort.Slice(messages, func(i, j int) bool {
return messages[i].Time < messages[j].Time
})
for _, m := range messages {
if err := sub(v, m); err != nil {
return err
}
}
return nil
@@ -1041,23 +1103,29 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
}
func (s *Server) updateStatsAndPrune() {
s.mu.Lock()
defer s.mu.Unlock()
log.Debug("Manager: Starting")
defer log.Debug("Manager: Finished")
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
// there is no mutex for the entire function.
// Expire visitors from rate visitors map
s.mu.Lock()
staleVisitors := 0
for ip, v := range s.visitors {
if v.Stale() {
log.Debug("Deleting stale visitor %s", v.ip)
log.Trace("Deleting stale visitor %s", v.ip)
delete(s.visitors, ip)
staleVisitors++
}
}
s.mu.Unlock()
log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
// Delete expired attachments
if s.fileCache != nil {
ids, err := s.messageCache.AttachmentsExpired()
if s.fileCache != nil && s.config.AttachmentExpiryDuration > 0 {
olderThan := time.Now().Add(-1 * s.config.AttachmentExpiryDuration)
ids, err := s.fileCache.Expired(olderThan)
if err != nil {
log.Warn("Error retrieving expired attachments: %s", err.Error())
} else if len(ids) > 0 {
@@ -1077,22 +1145,31 @@ func (s *Server) updateStatsAndPrune() {
log.Warn("Manager: Error pruning cache: %s", err.Error())
}
// Prune old topics, remove subscriptions without subscribers
var subscribers, messages int
// Message count per topic
var messages int
messageCounts, err := s.messageCache.MessageCounts()
if err != nil {
log.Warn("Manager: Cannot get message counts: %s", err.Error())
messageCounts = make(map[string]int) // Empty, so we can continue
}
for _, count := range messageCounts {
messages += count
}
// Remove subscriptions without subscribers
s.mu.Lock()
var subscribers int
for _, t := range s.topics {
subs := t.Subscribers()
msgs, err := s.messageCache.MessageCount(t.ID)
if err != nil {
log.Warn("Manager: Cannot get stats for topic %s: %s", t.ID, err.Error())
continue
}
if msgs == 0 && subs == 0 {
subs := t.SubscribersCount()
msgs, exists := messageCounts[t.ID]
if subs == 0 && (!exists || msgs == 0) {
log.Trace("Deleting empty topic %s", t.ID)
delete(s.topics, t.ID)
continue
}
subscribers += subs
messages += msgs
}
s.mu.Unlock()
// Mail stats
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
@@ -1105,8 +1182,11 @@ func (s *Server) updateStatsAndPrune() {
}
// Print stats
s.mu.Lock()
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
s.mu.Unlock()
log.Info("Stats: %d messages published, %d in cache, %d topic(s) active, %d subscriber(s), %d visitor(s), %d mails received (%d successful, %d failed), %d mails sent (%d successful, %d failed)",
s.messages, messages, len(s.topics), subscribers, len(s.visitors),
messagesCount, messages, topicsCount, subscribers, visitorsCount,
receivedMailTotal, receivedMailSuccess, receivedMailFailure,
sentMailTotal, sentMailSuccess, sentMailFailure)
}
@@ -1180,10 +1260,10 @@ func (s *Server) sendDelayedMessages() error {
}
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
s.mu.Lock()
defer s.mu.Unlock()
log.Debug("%s Sending delayed message", logMessagePrefix(v, m))
s.mu.Lock()
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
s.mu.Unlock()
if ok {
go func() {
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
@@ -1263,6 +1343,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Click != "" {
r.Header.Set("X-Click", m.Click)
}
if m.Icon != "" {
r.Header.Set("X-Icon", m.Icon)
}
if len(m.Actions) > 0 {
actionsStr, err := json.Marshal(m.Actions)
if err != nil {
@@ -1280,6 +1363,19 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
}
}
func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
if err != nil {
return err
}
if err := next(w, newRequest, v); err != nil {
return &errMatrix{pushKey: newRequest.Header.Get(matrixPushKeyHeader), err: err}
}
return nil
}
}
func (s *Server) authWrite(next handleFunc) handleFunc {
return s.withAuth(next, auth.PermissionWrite)
}
@@ -1344,8 +1440,12 @@ func (s *Server) visitor(r *http.Request) *visitor {
if err != nil {
ip = remoteAddr // This should not happen in real life; only in tests.
}
if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" {
ip = r.Header.Get("X-Forwarded-For")
if s.config.BehindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
// X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy,
// only the right-most address can be trusted (as this is the one added by our proxy server).
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
ip = strings.TrimSpace(util.LastString(ips, remoteAddr))
}
return s.visitorFromIP(ip)
}

View File

@@ -4,7 +4,12 @@
# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec.
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
#
# This setting is required for any of the following features:
# - attachments (to return a download URL)
# - e-mail sending (for the topic URL in the email footer)
# - iOS push notifications for self-hosted servers (to calculate the Firebase poll_request topic)
# - Matrix Push Gateway (to validate that the pushkey is correct)
#
# base-url:
@@ -21,6 +26,7 @@
# This can be useful to avoid port issues on local systems, and to simplify permissions.
#
# listen-unix: <socket-path>
# listen-unix-mode: <linux permissions, e.g. 0700>
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
#
@@ -32,14 +38,22 @@
#
# firebase-key-file: <filename>
# If set, messages are cached in a local SQLite database instead of only in-memory. This
# allows for service restarts without losing messages in support of the since= parameter.
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
# This allows for service restarts without losing messages in support of the since= parameter.
#
# The "cache-duration" parameter defines the duration for which messages will be buffered
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
# The cache file is created automatically, provided that the correct permissions are set.
#
# The "cache-startup-queries" parameter allows you to run commands when the database is initialized,
# e.g. to enable WAL mode (see https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)).
# Example:
# cache-startup-queries: |
# pragma journal_mode = WAL;
# pragma synchronous = normal;
# pragma temp_store = memory;
#
# Debian/RPM package users:
# Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package
# creates this folder for you.
@@ -50,6 +64,7 @@
#
# cache-file: <filename>
# cache-duration: "12h"
# cache-startup-queries:
# If set, access to the ntfy server and API can be controlled on a granular level using
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.

View File

@@ -148,6 +148,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,
"icon": m.Icon,
"title": m.Title,
"message": m.Message,
"encoding": m.Encoding,

View File

@@ -123,6 +123,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
m.Priority = 4
m.Tags = []string{"tag 1", "tag2"}
m.Click = "https://google.com"
m.Icon = "https://ntfy.sh/static/img/ntfy.png"
m.Title = "some title"
m.Actions = []*action{
{
@@ -173,6 +174,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
"icon": "https://ntfy.sh/static/img/ntfy.png",
"title": "some title",
"message": "this is a message",
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,
@@ -193,6 +195,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
"icon": "https://ntfy.sh/static/img/ntfy.png",
"title": "some title",
"message": "this is a message",
"actions": `[{"id":"123","action":"view","label":"Open page","clear":true,"url":"https://ntfy.sh"},{"id":"456","action":"http","label":"Close door","clear":false,"url":"https://door.com/close","method":"PUT","headers":{"really":"yes"}}]`,

174
server/server_matrix.go Normal file
View File

@@ -0,0 +1,174 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"net/http"
"strings"
)
// Matrix Push Gateway / UnifiedPush / ntfy integration:
//
// ntfy implements a Matrix Push Gateway (as defined in https://spec.matrix.org/v1.2/push-gateway-api/),
// in combination with UnifiedPush as the Provider Push Protocol (as defined in https://unifiedpush.org/developers/gateway/).
//
// In the picture below, ntfy is the Push Gateway (mostly in this file), as well as the Push Provider (ntfy's
// main functionality). UnifiedPush is the Provider Push Protocol, as implemented by the ntfy server and the
// ntfy Android app.
//
// +--------------------+ +-------------------+
// Matrix HTTP | | | |
// Notification Protocol | App Developer | | Device Vendor |
// | | | |
// +-------------------+ | +----------------+ | | +---------------+ |
// | | | | | | | | | |
// | Matrix homeserver +-----> Push Gateway +------> Push Provider | |
// | | | | | | | | | |
// +-^-----------------+ | +----------------+ | | +----+----------+ |
// | | | | | |
// Matrix | | | | | |
// Client/Server API + | | | | |
// | | +--------------------+ +-------------------+
// | +--+-+ |
// | | <-------------------------------------------+
// +---+ |
// | | Provider Push Protocol
// +----+
//
// Mobile Device or Client
//
// matrixRequest represents a Matrix message, as it is sent to a Push Gateway (as per
// this spec: https://spec.matrix.org/v1.2/push-gateway-api/).
//
// From the message, we only require the "pushkey", as it represents our target topic URL.
// A message may look like this (excerpt):
//
// {
// "notification": {
// "devices": [
// {
// "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1",
// ...
// }
// ]
// }
// }
type matrixRequest struct {
Notification *struct {
Devices []*struct {
PushKey string `json:"pushkey"`
} `json:"devices"`
} `json:"notification"`
}
// matrixResponse represents the response to a Matrix push gateway message, as defined
// in the spec (https://spec.matrix.org/v1.2/push-gateway-api/).
type matrixResponse struct {
Rejected []string `json:"rejected"`
}
// errMatrix represents an error when handing Matrix gateway messages
type errMatrix struct {
pushKey string
err error
}
func (e errMatrix) Error() string {
if e.err != nil {
return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error())
}
return fmt.Sprintf("message with push key %s rejected", e.pushKey)
}
const (
// matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest)
// along with the request. The push key is only used if an error occurs down the line.
matrixPushKeyHeader = "X-Matrix-Pushkey"
)
// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new
// HTTP request that looks like a normal ntfy request from it.
//
// It basically converts a Matrix push gatewqy request:
//
// POST /_matrix/push/v1/notify HTTP/1.1
// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } }
//
// to a ntfy request, looking like this:
//
// POST /upDAHJKFFDFD?up=1 HTTP/1.1
// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } }
func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) (*http.Request, error) {
if baseURL == "" {
return nil, errHTTPInternalErrorMissingBaseURL
}
body, err := util.Peek(r.Body, messageLimit)
if err != nil {
return nil, err
}
defer r.Body.Close()
if body.LimitReached {
return nil, errHTTPEntityTooLargeMatrixRequestTooLarge
}
var m matrixRequest
if err := json.Unmarshal(body.PeekedBytes, &m); err != nil {
return nil, errHTTPBadRequestMatrixMessageInvalid
} else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" {
return nil, errHTTPBadRequestMatrixMessageInvalid
}
pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316
if !strings.HasPrefix(pushKey, baseURL+"/") {
return nil, &errMatrix{pushKey: pushKey, err: wrapErrHTTP(errHTTPBadRequestMatrixPushkeyBaseURLMismatch, "received push key: %s, configured base URL: %s", pushKey, baseURL)}
}
newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))
if err != nil {
return nil, &errMatrix{pushKey: pushKey, err: err}
}
newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted
if r.Header.Get("X-Forwarded-For") != "" {
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
}
newRequest.Header.Set(matrixPushKeyHeader, pushKey)
return newRequest, nil
}
// writeMatrixDiscoveryResponse writes the UnifiedPush Matrix Gateway Discovery response to the given http.ResponseWriter,
// as per the spec (https://unifiedpush.org/developers/gateway/).
func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
_, err := io.WriteString(w, `{"unifiedpush":{"gateway":"matrix"}}`+"\n")
return err
}
// 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 {
log.Debug("%s Matrix gateway error: %s", logHTTPPrefix(v, r), err.Error())
return writeMatrixResponse(w, err.pushKey)
}
// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter
func writeMatrixSuccess(w http.ResponseWriter) error {
return writeMatrixResponse(w, "")
}
// writeMatrixResponse writes a matrixResponse to the given http.ResponseWriter, as defined in
// the spec (https://spec.matrix.org/v1.2/push-gateway-api/)
func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
rejected := make([]string, 0)
if rejectedPushKey != "" {
rejected = append(rejected, rejectedPushKey)
}
response := &matrixResponse{
Rejected: rejected,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,84 @@
package server
import (
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {
baseURL := "https://ntfy.sh"
maxLength := 4096
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
newRequest, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
require.Nil(t, err)
require.Equal(t, "POST", newRequest.Method)
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String())
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey"))
require.Equal(t, body, readAll(t, newRequest.Body))
}
func TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) {
baseURL := "https://ntfy.sh"
maxLength := 10 // Small
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
require.Equal(t, errHTTPEntityTooLargeMatrixRequestTooLarge, err)
}
func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) {
baseURL := "https://ntfy.sh"
maxLength := 4096
body := `this is not json`
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)
}
func TestMatrix_NewRequestFromMatrixJSON_NotAMatrixMessage(t *testing.T) {
baseURL := "https://ntfy.sh"
maxLength := 4096
body := `{"message":"this is not a matrix message, but valid json"}`
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)
}
func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {
baseURL := "https://ntfy.sh" // Mismatch!
maxLength := 4096
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
matrixErr, ok := err.(*errMatrix)
require.True(t, ok)
require.Equal(t, "invalid request: push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.err.Error())
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey)
}
func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
w := httptest.NewRecorder()
require.Nil(t, writeMatrixDiscoveryResponse(w))
require.Equal(t, 200, w.Result().StatusCode)
require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", w.Body.String())
}
func TestMatrix_WriteMatrixError(t *testing.T) {
w := httptest.NewRecorder()
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
v := newVisitor(newTestConfig(t), nil, "1.2.3.4")
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
require.Equal(t, 200, w.Result().StatusCode)
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
}
func TestMatrix_WriteMatrixSuccess(t *testing.T) {
w := httptest.NewRecorder()
require.Nil(t, writeMatrixSuccess(w))
require.Equal(t, 200, w.Result().StatusCode)
require.Equal(t, `{"rejected":[]}`+"\n", w.Body.String())
}

View File

@@ -6,6 +6,9 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/stretchr/testify/assert"
"io"
"log"
"math/rand"
"net/http"
"net/http/httptest"
@@ -63,6 +66,8 @@ func TestServer_PublishWithFirebase(t *testing.T) {
msg1 := toMessage(t, response.Body.String())
require.NotEmpty(t, msg1.ID)
require.Equal(t, "my first message", msg1.Message)
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
require.Equal(t, 1, len(sender.Messages()))
require.Equal(t, "my first message", sender.Messages()[0].Data["message"])
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body)
@@ -171,10 +176,6 @@ func TestServer_StaticSites(t *testing.T) {
require.Equal(t, 301, rr.Code)
// Docs test removed, it was failing annoyingly.
rr = request(t, s, "GET", "/example.html", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), "</html>")
}
func TestServer_WebEnabled(t *testing.T) {
@@ -185,9 +186,6 @@ func TestServer_WebEnabled(t *testing.T) {
rr := request(t, s, "GET", "/", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/example.html", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/config.js", "", nil)
require.Equal(t, 404, rr.Code)
@@ -201,9 +199,6 @@ func TestServer_WebEnabled(t *testing.T) {
rr = request(t, s2, "GET", "/", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/example.html", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/config.js", "", nil)
require.Equal(t, 200, rr.Code)
@@ -446,6 +441,53 @@ func TestServer_PublishAndPollSince(t *testing.T) {
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
}
func newMessageWithTimestamp(topic, message string, timestamp int64) *message {
m := newDefaultMessage(topic, message)
m.Time = timestamp
return m
}
func TestServer_PollSinceID_MultipleTopics(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 1", 1655740277)))
markerMessage := newMessageWithTimestamp("mytopic2", "test 2", 1655740283)
require.Nil(t, s.messageCache.AddMessage(markerMessage))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289)))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293)))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297)))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303)))
response := request(t, s, "GET", fmt.Sprintf("/mytopic1,mytopic2/json?poll=1&since=%s", markerMessage.ID), "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 4, len(messages))
require.Equal(t, "test 3", messages[0].Message)
require.Equal(t, "mytopic1", messages[0].Topic)
require.Equal(t, "test 4", messages[1].Message)
require.Equal(t, "mytopic2", messages[1].Topic)
require.Equal(t, "test 5", messages[2].Message)
require.Equal(t, "mytopic1", messages[2].Topic)
require.Equal(t, "test 6", messages[3].Message)
require.Equal(t, "mytopic2", messages[3].Topic)
}
func TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289)))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293)))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297)))
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303)))
response := request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1&since=NoMatchForID", "", nil)
messages := toMessages(t, response.Body.String())
require.Equal(t, 4, len(messages))
require.Equal(t, "test 3", messages[0].Message)
require.Equal(t, "test 4", messages[1].Message)
require.Equal(t, "test 5", messages[2].Message)
require.Equal(t, "test 6", messages[3].Message)
}
func TestServer_PublishViaGET(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -916,6 +958,70 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
require.Equal(t, "this is a unifiedpush text message", m.Message)
}
func TestServer_MatrixGateway_Discovery_Success(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String())
}
func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
c := newTestConfig(t)
c.BaseURL = ""
s := newTestServer(t, c)
response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil)
require.Equal(t, 500, response.Code)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 50003, err.Code)
}
func TestServer_MatrixGateway_Push_Success(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, notification, m.Message)
}
func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String())
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, "", response.Body.String()) // Empty!
}
func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
notification := `{"message":"this is not really a Matrix message"}`
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
require.Equal(t, 400, response.Code)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 40019, err.Code)
require.Equal(t, 400, err.HTTPCode)
}
func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
c := newTestConfig(t)
c.BaseURL = ""
s := newTestServer(t, c)
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
require.Equal(t, 500, response.Code)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 50003, err.Code)
require.Equal(t, 500, err.HTTPCode)
}
func TestServer_PublishActions_AndPoll(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
@@ -940,7 +1046,7 @@ func TestServer_PublishAsJSON(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
`"delay":"30min"}`
`"icon":"https://ntfy.sh/static/img/ntfy.png", "delay":"30min"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
@@ -952,6 +1058,8 @@ func TestServer_PublishAsJSON(t *testing.T) {
require.Equal(t, "http://google.com", m.Attachment.URL)
require.Equal(t, "google.pdf", m.Attachment.Name)
require.Equal(t, "http://ntfy.sh", m.Click)
require.Equal(t, "https://ntfy.sh/static/img/ntfy.png", m.Icon)
require.Equal(t, 4, m.Priority)
require.True(t, m.Time > time.Now().Unix()+29*60)
require.True(t, m.Time < time.Now().Unix()+31*60)
@@ -964,6 +1072,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
time.Sleep(100 * time.Millisecond) // E-Mail publishing happens in a Go routine
m := toMessage(t, response.Body.String())
require.Equal(t, "mytopic", m.Topic)
@@ -1026,12 +1135,19 @@ func TestServer_PublishAttachment(t *testing.T) {
require.Equal(t, "", msg.Sender) // Should never be returned
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
// GET
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
response = request(t, s, "GET", path, "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, "5000", response.Header().Get("Content-Length"))
require.Equal(t, content, response.Body.String())
// HEAD
response = request(t, s, "HEAD", path, "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, "5000", response.Header().Get("Content-Length"))
require.Equal(t, "", response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
require.Nil(t, err)
@@ -1267,6 +1383,84 @@ func TestServer_PublishAttachmentUserStats(t *testing.T) {
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
}
func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11"
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
v := s.visitor(r)
require.Equal(t, "8.9.10.11", v.ip)
}
func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11"
r.Header.Set("X-Forwarded-For", "1.1.1.1")
v := s.visitor(r)
require.Equal(t, "1.1.1.1", v.ip)
}
func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
c := newTestConfig(t)
c.BehindProxy = true
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11"
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
v := s.visitor(r)
require.Equal(t, "234.5.2.1", v.ip)
}
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
count := 50000
c := newTestConfig(t)
c.TotalTopicLimit = 50001
c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
s := newTestServer(t, c)
// Add lots of messages
log.Printf("Adding %d messages", count)
start := time.Now()
messages := make([]*message, 0)
for i := 0; i < count; i++ {
topicID := fmt.Sprintf("topic%d", i)
_, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array
require.Nil(t, err)
messages = append(messages, newDefaultMessage(topicID, "some message"))
}
require.Nil(t, s.messageCache.addMessages(messages))
log.Printf("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond))
// Update stats
statsChan := make(chan bool)
go func() {
log.Printf("Updating stats")
start := time.Now()
s.updateStatsAndPrune()
log.Printf("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
statsChan <- true
}()
time.Sleep(50 * time.Millisecond) // Make sure it starts first
// Publish message (during stats update)
log.Printf("Publishing message")
start = time.Now()
response := request(t, s, "PUT", "/mytopic", "some body", nil)
m := toMessage(t, response.Body.String())
assert.Equal(t, "some body", m.Message)
assert.True(t, time.Since(start) < 100*time.Millisecond)
log.Printf("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
// Wait for all goroutines
<-statsChan
log.Printf("Done: Waiting for all locks")
}
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
@@ -1341,3 +1535,11 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
func basicAuth(s string) string {
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
}
func readAll(t *testing.T, rc io.ReadCloser) string {
b, err := io.ReadAll(rc)
if err != nil {
t.Fatal(err)
}
return string(b)
}

View File

@@ -3,7 +3,6 @@ package server
import (
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require"
"io"
"net"
"net/http"
"strings"
@@ -304,14 +303,6 @@ func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Reques
return conf, backend
}
func readAll(t *testing.T, rc io.ReadCloser) string {
b, err := io.ReadAll(rc)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
ip, err := net.ResolveIPAddr("ip", remoteAddr)
if err != nil {

View File

@@ -44,14 +44,19 @@ func (t *topic) Unsubscribe(id int) {
// Publish asynchronously publishes to all subscribers
func (t *topic) Publish(v *visitor, m *message) error {
go func() {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.subscribers) > 0 {
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(t.subscribers))
for _, s := range t.subscribers {
if err := s(v, m); err != nil {
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
}
// We want to lock the topic as short as possible, so we make a shallow copy of the
// subscribers map here. Actually sending out the messages then doesn't have to lock.
subscribers := t.subscribersCopy()
if len(subscribers) > 0 {
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(subscribers))
for _, s := range subscribers {
// 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.
go func(s subscriber) {
if err := s(v, m); err != nil {
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
}
}(s)
}
} else {
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
@@ -60,9 +65,20 @@ func (t *topic) Publish(v *visitor, m *message) error {
return nil
}
// Subscribers returns the number of subscribers to this topic
func (t *topic) Subscribers() int {
// SubscribersCount returns the number of subscribers to this topic
func (t *topic) SubscribersCount() int {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.subscribers)
}
// subscribersCopy returns a shallow copy of the subscribers map
func (t *topic) subscribersCopy() map[int]subscriber {
t.mu.Lock()
defer t.mu.Unlock()
subscribers := make(map[int]subscriber)
for k, v := range t.subscribers {
subscribers[k] = v
}
return subscribers
}

View File

@@ -29,6 +29,7 @@ type message struct {
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Icon string `json:"icon,omitempty"`
Actions []*action `json:"actions,omitempty"`
Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
@@ -72,6 +73,7 @@ type publishMessage struct {
Priority int `json:"priority"`
Tags []string `json:"tags"`
Click string `json:"click"`
Icon string `json:"icon"`
Actions []action `json:"actions"`
Attach string `json:"attach"`
Filename string `json:"filename"`
@@ -174,7 +176,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") {
priority, err := util.ParsePriority(p)
if err != nil {
return nil, err
return nil, errHTTPBadRequestPriorityInvalid
}
priorityFilter = append(priorityFilter, priority)
}

View File

@@ -3,8 +3,10 @@ package server
import (
"fmt"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/util"
"net/http"
"strings"
"unicode/utf8"
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
@@ -58,3 +60,32 @@ func logHTTPPrefix(v *visitor, r *http.Request) string {
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)
}

View File

@@ -1,8 +1,12 @@
package server
import (
"bytes"
"fmt"
"github.com/stretchr/testify/require"
"math/rand"
"net/http"
"strings"
"testing"
)
@@ -27,3 +31,47 @@ func TestReadBoolParam(t *testing.T) {
require.Equal(t, false, up)
require.Equal(t, true, firebase)
}
func TestRenderHTTPRequest_ValidShort(t *testing.T) {
r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader("some message"))
r.Header.Set("Title", "A title")
expected := `POST /mytopic?p=2 HTTP/1.1
Title: A title
some message`
require.Equal(t, expected, renderHTTPRequest(r))
}
func TestRenderHTTPRequest_ValidLong(t *testing.T) {
body := strings.Repeat("a", 5000)
r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader(body))
r.Header.Set("Accept", "*/*")
expected := `POST /mytopic?p=2 HTTP/1.1
Accept: */*
` + strings.Repeat("a", 4096) + " ... (peeked 4096 bytes)"
require.Equal(t, expected, renderHTTPRequest(r))
}
func TestRenderHTTPRequest_InvalidShort(t *testing.T) {
body := []byte{0xc3, 0x28}
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body))
r.Header.Set("Accept", "*/*")
expected := `GET /mytopic/json?since=all HTTP/1.1
Accept: */*
(peeked bytes not UTF-8, 2 bytes, hex: c328)`
require.Equal(t, expected, renderHTTPRequest(r))
}
func TestRenderHTTPRequest_InvalidLong(t *testing.T) {
body := make([]byte, 5000)
rand.Read(body)
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body))
r.Header.Set("Accept", "*/*")
expected := `GET /mytopic/json?since=all HTTP/1.1
Accept: */*
(peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf("%x", body[:4096]) + ` ...)`
require.Equal(t, expected, renderHTTPRequest(r))
}

View File

@@ -11,14 +11,13 @@ import (
// CachingEmbedFS is a wrapper around embed.FS that allows setting a ModTime, so that the
// default static file server can send 304s back. It can be used like this:
//
// var (
// //go:embed docs
// docsStaticFs embed.FS
// docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
// )
//
// http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r)
// var (
// //go:embed docs
// docsStaticFs embed.FS
// docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
// )
//
// http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r)
type CachingEmbedFS struct {
ModTime time.Time
FS embed.FS

View File

@@ -3,7 +3,6 @@ package util
import (
"compress/gzip"
"io"
"io/ioutil"
"net/http"
"strings"
"sync"
@@ -32,7 +31,7 @@ func Gzip(next http.Handler) http.Handler {
var gzPool = sync.Pool{
New: func() interface{} {
w := gzip.NewWriter(ioutil.Discard)
w := gzip.NewWriter(io.Discard)
return w
},
}

View File

@@ -18,7 +18,8 @@ type PeekedReadCloser struct {
closed bool
}
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser.
// It does not return an error if limit is reached. Instead, LimitReached will be set to true.
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))

View File

@@ -26,6 +26,7 @@ var (
randomMutex = sync.Mutex{}
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
errInvalidPriority = errors.New("invalid priority")
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
)
// FileExists checks if a file exists, and returns true if it does
@@ -88,6 +89,14 @@ func SplitKV(s string, sep string) (key string, value string) {
return "", strings.TrimSpace(kv[0])
}
// LastString returns the last string in a slice, or def if s is empty
func LastString(s []string, def string) string {
if len(s) == 0 {
return def
}
return s[len(s)-1]
}
// RandomString returns a random string with a given length
func RandomString(length int) string {
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
@@ -112,41 +121,10 @@ func ValidRandomString(s string, length int) bool {
return true
}
// DurationToHuman converts a duration to a human-readable format
func DurationToHuman(d time.Duration) (str string) {
if d == 0 {
return "0"
}
d = d.Round(time.Second)
days := d / time.Hour / 24
if days > 0 {
str += fmt.Sprintf("%dd", days)
}
d -= days * time.Hour * 24
hours := d / time.Hour
if hours > 0 {
str += fmt.Sprintf("%dh", hours)
}
d -= hours * time.Hour
minutes := d / time.Minute
if minutes > 0 {
str += fmt.Sprintf("%dm", minutes)
}
d -= minutes * time.Minute
seconds := d / time.Second
if seconds > 0 {
str += fmt.Sprintf("%ds", seconds)
}
return
}
// ParsePriority parses a priority string into its equivalent integer value
func ParsePriority(priority string) (int, error) {
switch strings.TrimSpace(strings.ToLower(priority)) {
p := strings.TrimSpace(strings.ToLower(priority))
switch p {
case "":
return 0, nil
case "1", "min":
@@ -160,6 +138,11 @@ func ParsePriority(priority string) (int, error) {
case "5", "max", "urgent":
return 5, nil
default:
// Ignore new HTTP Priority header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
// Cloudflare adds this to requests when forwarding to the backend (ntfy), so we just ignore it.
if strings.HasPrefix(p, "u=") {
return 3, nil
}
return 0, errInvalidPriority
}
}
@@ -278,3 +261,23 @@ func MaybeMarshalJSON(v interface{}) string {
}
return string(jsonBytes)
}
// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
//
// Warning: Never use this function with the intent to run the resulting command.
//
// Example:
//
// []string{"ls", "-al", "Document Folder"} -> ls -al "Document Folder"
func QuoteCommand(command []string) string {
var quoted []string
for _, c := range command {
if noQuotesRegex.MatchString(c) {
quoted = append(quoted, c)
} else {
quoted = append(quoted, fmt.Sprintf(`"%s"`, c))
}
}
return strings.Join(quoted, " ")
}

View File

@@ -2,36 +2,11 @@ package util
import (
"github.com/stretchr/testify/require"
"io/ioutil"
"os"
"path/filepath"
"testing"
"time"
)
func TestDurationToHuman_SevenDays(t *testing.T) {
d := 7 * 24 * time.Hour
require.Equal(t, "7d", DurationToHuman(d))
}
func TestDurationToHuman_MoreThanOneDay(t *testing.T) {
d := 49 * time.Hour
require.Equal(t, "2d1h", DurationToHuman(d))
}
func TestDurationToHuman_LessThanOneDay(t *testing.T) {
d := 17*time.Hour + 15*time.Minute
require.Equal(t, "17h15m", DurationToHuman(d))
}
func TestDurationToHuman_TenOfThings(t *testing.T) {
d := 10*time.Hour + 10*time.Minute + 10*time.Second
require.Equal(t, "10h10m10s", DurationToHuman(d))
}
func TestDurationToHuman_Zero(t *testing.T) {
require.Equal(t, "0", DurationToHuman(0))
}
func TestRandomString(t *testing.T) {
s1 := RandomString(10)
s2 := RandomString(10)
@@ -44,7 +19,7 @@ func TestRandomString(t *testing.T) {
func TestFileExists(t *testing.T) {
filename := filepath.Join(t.TempDir(), "somefile.txt")
require.Nil(t, ioutil.WriteFile(filename, []byte{0x25, 0x86}, 0600))
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
require.True(t, FileExists(filename))
require.False(t, FileExists(filename+".doesnotexist"))
}
@@ -85,13 +60,22 @@ func TestParsePriority(t *testing.T) {
}
func TestParsePriority_Invalid(t *testing.T) {
priorities := []string{"-1", "6", "aa", "-"}
priorities := []string{"-1", "6", "aa", "-", "o=1"}
for _, priority := range priorities {
_, err := ParsePriority(priority)
require.Equal(t, errInvalidPriority, err)
}
}
func TestParsePriority_HTTPSpecPriority(t *testing.T) {
priorities := []string{"u=1", "u=3", "u=7, i"} // see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority
for _, priority := range priorities {
actual, err := ParsePriority(priority)
require.Nil(t, err)
require.Equal(t, 3, actual) // Always expect 3!
}
}
func TestPriorityString(t *testing.T) {
priorities := []int{0, 1, 2, 3, 4, 5}
expected := []string{"default", "min", "low", "default", "high", "max"}
@@ -157,3 +141,14 @@ func TestSplitKV(t *testing.T) {
require.Equal(t, "mykey", key)
require.Equal(t, "value=with=separator", value)
}
func TestLastString(t *testing.T) {
require.Equal(t, "last", LastString([]string{"first", "second", "last"}, "default"))
require.Equal(t, "default", LastString([]string{}, "default"))
}
func TestQuoteCommand(t *testing.T) {
require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
require.Equal(t, `/home/sweet/home "Äöü this is a test" "\a\b"`, QuoteCommand([]string{"/home/sweet/home", "Äöü this is a test", "\\a\\b"}))
}

7210
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"action_bar_show_menu": "Show menu",
"action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Settings",
"action_bar_subscription_settings": "Subscription settings",
"action_bar_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications",
"action_bar_unsubscribe": "Unsubscribe",
@@ -24,6 +25,7 @@
"alert_grant_button": "Grant now",
"alert_not_supported_title": "Notifications not supported",
"alert_not_supported_description": "Notifications are not supported in your browser.",
"alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
"notifications_list": "Notifications list",
"notifications_list_item": "Notification",
"notifications_mark_read": "Mark as read",
@@ -58,6 +60,11 @@
"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_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
"subscription_settings_dialog_title": "Subscription settings",
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
"subscription_settings_dialog_display_name_placeholder": "Display name",
"subscription_settings_button_cancel": "Cancel",
"subscription_settings_button_save": "Save",
"notifications_loading": "Loading notifications …",
"publish_dialog_title_topic": "Publish to {{topic}}",
"publish_dialog_title_no_topic": "Publish notification",

View File

@@ -74,7 +74,7 @@
"publish_dialog_drop_file_here": "Suelta el archivo aquí",
"emoji_picker_search_placeholder": "Buscar emojis",
"subscribe_dialog_subscribe_title": "Suscribirse al tópico",
"subscribe_dialog_subscribe_description": "Los tópicos pueden no estar protegidos por contraseña, así que elija un nombre que no sea fácil de adivinar. Una vez suscrito, puede hacer PUT/PIST de notificaciones.",
"subscribe_dialog_subscribe_description": "Los tópicos pueden no estar protegidos por contraseña, así que elija un nombre que no sea fácil de adivinar. Una vez suscrito, puede hacer PUT/POST de notificaciones.",
"subscribe_dialog_subscribe_topic_placeholder": "Nombre del tópico, ej. phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Usar otro servidor",
"subscribe_dialog_login_title": "Es necesario iniciar sesión",

View File

@@ -59,15 +59,15 @@
"notifications_more_details": "Pour plus d'information, visitez <websiteLink>le site web</websiteLink> ou <docsLink>la documentation</docsLink>.",
"publish_dialog_title_placeholder": "Titre de la notification, par ex. Alerte d'espace disque",
"publish_dialog_topic_placeholder": "Nom du sujet, par ex. phil_alerts",
"publish_dialog_delay_placeholder": "Délai de la délivrance, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
"publish_dialog_delay_placeholder": "Délai de réception, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
"publish_dialog_other_features": "Autres fonctionnalités :",
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
"publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1",
"publish_dialog_priority_label": "Priorité",
"publish_dialog_click_label": "Cliquer sur l'URL",
"publish_dialog_click_placeholder": "URL ouverte quand la notification est cliquée",
"publish_dialog_click_label": "URL du clic",
"publish_dialog_click_placeholder": "URL ouverte lors d'un clic sur la notification",
"publish_dialog_attach_label": "URL de la pièce jointe",
"publish_dialog_attach_placeholder": "Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_label": "Nom du fichier",
@@ -131,7 +131,7 @@
"prefs_users_dialog_button_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>.",
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_stack_trace": "Trace de pile d'appels",
"error_boundary_gathering_info": "Récupérer plus d'information…",
"prefs_notifications_delete_after_one_week": "Après une semaine",
"prefs_notifications_delete_after_one_month": "Après un mois",
@@ -152,5 +152,40 @@
"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_button_cancel_sending": "Annuler l'envoi",
"prefs_users_dialog_button_save": "Enregistrer"
"prefs_users_dialog_button_save": "Enregistrer",
"notifications_new_indicator": "Nouvelle notification",
"publish_dialog_delay_reset": "Retirer le délai de réception",
"notifications_list_item": "Notification",
"notifications_priority_x": "Priorité {{priority}}",
"notifications_mark_read": "Marquer comme lu",
"notifications_attachment_image": "Image jointe",
"notifications_delete": "Supprimer",
"notifications_attachment_file_video": "fichier vidéo",
"notifications_attachment_file_audio": "fichier audio",
"prefs_users_table": "Liste des utilisateurs",
"notifications_attachment_file_image": "fichier image",
"notifications_attachment_file_app": "fichier d'application Android",
"notifications_attachment_file_document": "autre document",
"prefs_notifications_sound_play": "Jouer le son sélectionné",
"error_boundary_unsupported_indexeddb_description": "L'application web ntfy a besoin d'IndexedDB pour fonctionner, mais votre navigateur ne supporte pas IndexedDB en navigation privée.<br/><br/>Bien que cela soit regrettable, il serait peu utile d'utiliser l'application web ntfy en navigation privée, car tout est stocké par votre navigateur. Vous pouvez vous renseigner plus amplement à ce propos <githubLink>dans ce ticket GitHub</githubLink>, ou en parler avec nous sur <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"action_bar_show_menu": "Montrer le menu",
"action_bar_toggle_mute": "Mettre en sourdine/réactiver les notifications",
"action_bar_toggle_action_menu": "Ouvrir/fermer le menu d'actions",
"publish_dialog_emoji_picker_show": "Choisir un emoji",
"publish_dialog_topic_reset": "Réinitialiser le sujet",
"message_bar_publish": "Publier le message",
"nav_button_muted": "Notifications en sourdine",
"nav_button_connecting": "connexion en cours",
"notifications_list": "Liste des notifications",
"message_bar_show_dialog": "Montrer le formulaire de publication",
"action_bar_logo_alt": "Logo de ntfy",
"publish_dialog_click_reset": "Retirer l'URL du clic",
"publish_dialog_email_reset": "Retirer le transfert par courriel",
"publish_dialog_attach_reset": "Retirer l'URL de la pièce jointe",
"emoji_picker_search_clear": "Effacer la recherche",
"subscribe_dialog_subscribe_base_url_label": "URL du service",
"prefs_users_edit_button": "Éditer l'utilisateur",
"prefs_users_delete_button": "Supprimer l'utilisateur",
"error_boundary_unsupported_indexeddb_title": "Navigation privée non prise en charge",
"publish_dialog_attached_file_remove": "Retirer le fichier joint"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -62,8 +62,8 @@
"notifications_click_copy_url_title": "URL naar klembord kopiëren",
"notifications_click_copy_url_button": "Link kopiëren",
"notifications_click_open_button": "Link openen",
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL.",
"notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL. Hier is een voorbeeld met één van je onderwerpen.",
"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_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp. Hier is een voorbeeld met één van je onderwerpen.",
"notifications_no_subscriptions_title": "Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.",
"notifications_no_subscriptions_description": "Klik op de \"{{linktext}}\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.",
"notifications_example": "Voorbeeld",
@@ -148,12 +148,12 @@
"prefs_notifications_sound_title": "Meldingsgeluid",
"prefs_notifications_sound_description_none": "Notificaties zullen geen geluid geven",
"prefs_notifications_sound_play": "Geselecteerd geluid afspelen",
"prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} afspelen",
"prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} geluid afspelen",
"prefs_notifications_sound_no_sound": "Geen geluid",
"prefs_notifications_min_priority_title": "Minimale prioriteit",
"prefs_notifications_min_priority_description_any": "Toon alle notificaties, ongeacht prioriteit",
"prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit is {{number}} ({{name}}) of hoger",
"prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit is 5 (maximaal)",
"prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit {{number}} ({{name}}) is of hoger",
"prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit 5 (maximaal) is",
"prefs_notifications_min_priority_any": "Elke prioriteit",
"prefs_notifications_min_priority_low_and_higher": "Lage prioriteit en hoger",
"prefs_notifications_min_priority_default_and_higher": "Standaard prioriteit en hoger",

View File

@@ -0,0 +1,191 @@
{
"action_bar_send_test_notification": "Wyślij powiadomienie testowe",
"action_bar_clear_notifications": "Wyczyść powiadomienia",
"action_bar_toggle_mute": "Włączanie/wyłączanie wyciszania powiadomień",
"action_bar_toggle_action_menu": "Otwórz/zamknij menu działań",
"message_bar_type_message": "Wpisz wiadomość tutaj",
"message_bar_error_publishing": "Błąd przy wysyłaniu powiadomienia",
"message_bar_show_dialog": "Pokaż okno dialogowe publikacji",
"nav_button_all_notifications": "Wszystkie powiadomienia",
"nav_button_documentation": "Dokumentacja",
"nav_button_muted": "Powiadomienia wyciszone",
"alert_grant_title": "Powiadomienia są wyłączone",
"alert_grant_description": "Udziel przeglądarce pozwolenia na wyświetlanie powiadomień na pulpicie.",
"alert_grant_button": "Pozwól teraz",
"alert_not_supported_title": "Powiadomienia nie są obsługiwane",
"alert_not_supported_description": "Powiadomienia nie są obsługiwane przez Twoją przeglądarkę.",
"notifications_list": "Lista powiadomień",
"notifications_list_item": "Powiadomienie",
"notifications_mark_read": "Oznacz jako przeczytane",
"notifications_delete": "Usuń",
"notifications_copied_to_clipboard": "Skopiowano do schowka",
"notifications_tags": "Tagi",
"message_bar_publish": "Opublikuj powiadomienie",
"nav_topics_title": "Subskrybowane tematy",
"nav_button_settings": "Ustawienia",
"nav_button_publish_message": "Opublikuj powiadomienie",
"nav_button_subscribe": "Zasubskrybuj temat",
"nav_button_connecting": "łączenie",
"notifications_attachment_image": "Obraz załącznika",
"notifications_attachment_copy_url_button": "Kopiuj Adres URL",
"notifications_attachment_link_expires": "Łącze wygasa w dniu {{date}}",
"notifications_attachment_link_expired": "Łącze do pobrania wygasło",
"notifications_attachment_file_image": "plik graficzny",
"notifications_attachment_file_video": "plik wideo",
"notifications_attachment_file_audio": "plik audio",
"notifications_attachment_file_app": "plik aplikacji Android",
"notifications_attachment_file_document": "inny dokument",
"notifications_click_copy_url_title": "Skopiuj adres URL do schowka",
"notifications_click_open_button": "Otwórz łącze",
"notifications_actions_open_url_title": "Przejdź do {{url}}",
"notifications_actions_not_supported": "Ta akcja nie jest obsługiwana w aplikacji internetowej",
"notifications_actions_http_request_title": "Wyślij HTTP {{method}} do {{url}}",
"notifications_none_for_topic_title": "Nie otrzymałeś jeszcze żadnych powiadomień dla tego tematu.",
"notifications_none_for_any_description": "Aby wysłać powiadomienia do tematu, wyślij PUT/POST do adresu URL tematu. Oto przykład z jednym z twoich tematów.",
"notifications_no_subscriptions_title": "Wygląda na to, że nie masz jeszcze żadnych subskrypcji.",
"notifications_no_subscriptions_description": "Kliknij łącze \"{{linktext}}\", aby stworzyć lub zasubskrybować temat. Następnie możesz wysyłać wiadomości za pomocą PUT lub POST i otrzymywać powiadomienia tutaj.",
"notifications_example": "Przykład",
"notifications_loading": "Ładowanie powiadomień …",
"publish_dialog_title_topic": "Opublikuj do {{topic}}",
"publish_dialog_title_no_topic": "Opublikuj powiadomienie",
"publish_dialog_progress_uploading": "Przesyłanie …",
"publish_dialog_progress_uploading_detail": "Przesyłanie {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Powiadomienie wysłane",
"publish_dialog_attachment_limits_file_and_quota_reached": "przekracza limit rozmiaru pliku {{fileSizeLimit}}, pozostaje {{remainingBytes}}",
"publish_dialog_attachment_limits_file_reached": "przekracza limit rozmiaru pliku {{filesizeLimit}}",
"publish_dialog_attachment_limits_quota_reached": "przekracza limit, {{remainingBytes}} pozostało",
"publish_dialog_emoji_picker_show": "Wybierz emotkę",
"publish_dialog_priority_min": "Min. priorytet",
"publish_dialog_priority_low": "Niski priorytet",
"publish_dialog_base_url_label": "Adres URL usługi",
"publish_dialog_base_url_placeholder": "Adres URL usługi, np. https://example.com",
"publish_dialog_topic_label": "Nazwa tematu",
"publish_dialog_topic_placeholder": "Nazwa tematu, np. moje_alerty",
"publish_dialog_topic_reset": "Resetuj temat",
"publish_dialog_title_label": "Tytuł",
"publish_dialog_title_placeholder": "Tytuł notyfikacji, np. Niski poziom baterrii",
"publish_dialog_message_label": "Wiadomość",
"publish_dialog_message_placeholder": "Wpisz wiadomość tutaj",
"publish_dialog_tags_label": "Tagi",
"publish_dialog_tags_placeholder": "Lista tagów oddzielona przecinkami, np. ostrzeżenie, srv1-backup",
"publish_dialog_priority_label": "Priorytet",
"publish_dialog_click_label": "Kliknij Adres URL",
"publish_dialog_click_placeholder": "Adres URL, który ma być otwarty po kliknięciu na powiadomienie",
"publish_dialog_click_reset": "Usuń adres URL kliknięcia",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Adres, na który ma być wysłane powiadomienie, np. phil@example.com",
"publish_dialog_email_reset": "Usuń przekazywanie wiadomości email",
"publish_dialog_attach_label": "Adres URL załącznika",
"publish_dialog_attach_placeholder": "Dołączenie pliku z adresu URL, np. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Usuń adres URL załącznika",
"publish_dialog_filename_label": "Nazwa pliku",
"publish_dialog_filename_placeholder": "Nazwa pliku załącznika",
"publish_dialog_delay_label": "Opóźnienie",
"publish_dialog_delay_reset": "Usuń opóźnione dostarczenie",
"publish_dialog_other_features": "Inne funkcje:",
"publish_dialog_chip_click_label": "Adres URL kliknięcia",
"publish_dialog_chip_email_label": "Przekaż na email",
"publish_dialog_chip_attach_url_label": "Dołącz plik z adresu URL",
"publish_dialog_chip_attach_file_label": "Dołącz plik lokalny",
"publish_dialog_chip_delay_label": "Opóźnienie dostawy",
"publish_dialog_chip_topic_label": "Zmień temat",
"publish_dialog_details_examples_description": "Przykłady i szczegółowe informacje na temat wszystkich opcji można znaleźć w <docsLink>dokumentacji</docsLink>.",
"publish_dialog_button_cancel_sending": "Anuluj wysyłanie",
"publish_dialog_button_send": "Wyślij",
"publish_dialog_checkbox_publish_another": "Wyślij kolejną wiadomość",
"publish_dialog_attached_file_title": "Załączony plik:",
"publish_dialog_attached_file_filename_placeholder": "Nazwa pliku załącznika",
"publish_dialog_drop_file_here": "Upuść plik tutaj",
"emoji_picker_search_placeholder": "Szukaj emotki",
"emoji_picker_search_clear": "Wyczyść wyszukiwanie",
"subscribe_dialog_subscribe_title": "Zasubskrybuj temat",
"subscribe_dialog_subscribe_topic_placeholder": "Nazwa tematu, np. moje_alerty",
"subscribe_dialog_subscribe_use_another_label": "Użyj innego serwera",
"subscribe_dialog_subscribe_base_url_label": "Adres URL usługi",
"subscribe_dialog_subscribe_button_cancel": "Anuluj",
"subscribe_dialog_login_description": "Ten temat jest chroniony hasłem. Proszę podać nazwę użytkownika i hasło, aby zasubskrybować.",
"subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil",
"subscribe_dialog_login_password_label": "Hasło",
"publish_dialog_button_cancel": "Anuluj",
"subscribe_dialog_login_button_back": "Powrót",
"subscribe_dialog_login_button_login": "Zaloguj się",
"subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień",
"subscribe_dialog_error_user_anonymous": "anonim",
"prefs_notifications_title": "Powiadomienia",
"prefs_notifications_sound_title": "Dźwięk powiadomienia",
"prefs_notifications_sound_description_none": "Brak dźwięku po otrzymaniu powiadomienia",
"prefs_notifications_sound_description_some": "Odtwarzaj dźwięk {{sound}}, gdy nadejdzie powiadomienie",
"prefs_notifications_sound_play": "Odtwórz wybrany dźwięk",
"prefs_notifications_min_priority_title": "Minimalny priorytet",
"prefs_notifications_min_priority_description_any": "Pokaż wszystkie powiadomienia, niezależnie od priorytetu",
"prefs_notifications_min_priority_description_x_or_higher": "Pokazuj powiadomienia, gdy ich priorytet to {{number}} ({{name}}) lub wyższy",
"prefs_notifications_min_priority_description_max": "Pokaż powiadomienia, jeśli priorytet wynosi 5 (max)",
"prefs_notifications_min_priority_any": "Dowolny priorytet",
"prefs_notifications_min_priority_low_and_higher": "Niski priorytet i wyższy",
"prefs_notifications_min_priority_default_and_higher": "Priorytet standardowy i wyższy",
"prefs_notifications_min_priority_high_and_higher": "Wysoki priorytet i wyższy",
"prefs_notifications_delete_after_one_day": "Po jednym dniu",
"prefs_notifications_delete_after_one_week": "Po tygodniu",
"prefs_notifications_delete_after_one_month": "Po miesiącu",
"prefs_notifications_delete_after_never_description": "Powiadomienia nigdy nie są automatycznie usuwane",
"prefs_notifications_delete_after_three_hours_description": "Powiadomienia są automatycznie usuwane po trzech godzinach",
"prefs_notifications_delete_after_one_day_description": "Powiadomienia są automatycznie usuwane po jednym dniu",
"prefs_notifications_delete_after_one_month_description": "Powiadomienia są automatycznie usuwane po upływie jednego miesiąca",
"prefs_notifications_delete_after_one_week_description": "Powiadomienia są automatycznie usuwane po upływie jedego tygodnia",
"prefs_users_title": "Zarządzaj użytkownikami",
"prefs_users_description": "Dodaj/usuń użytkowników dla tematów chronionych hasłem. Uwaga: Nazwa użytkownika i hasło są przechowywane w lokalnej pamięci przeglądarki.",
"prefs_users_table": "Tabela użytkowników",
"prefs_users_add_button": "Dodaj użytkownika",
"notifications_attachment_open_button": "Otwórz załącznik",
"prefs_users_edit_button": "Edytuj użytkownika",
"prefs_users_delete_button": "Usuń użytkownika",
"prefs_users_table_base_url_header": "Adres URL usługi",
"prefs_users_dialog_title_add": "Dodaj użytkownika",
"prefs_users_dialog_button_cancel": "Anuluj",
"prefs_users_dialog_button_add": "Dodaj",
"prefs_users_dialog_button_save": "Zapisz",
"prefs_appearance_title": "Wygląd",
"prefs_appearance_language_title": "Język",
"error_boundary_title": "Oh nie, ntfy przestało działać",
"error_boundary_description": "Oczywiście, to nie miało się wydarzyć. Bardzo przepraszam za to.<br/>Jeśli masz minutę, proszę <githubLink>zgłoś to na GitHubie</githubLink>, albo daj nam znać przez <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Kopiuj stack trace",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_gathering_info": "Zbierz więcej informacji …",
"error_boundary_unsupported_indexeddb_title": "Prywatne karty przeglądarki nie są obsługiwane",
"action_bar_show_menu": "Pokaż menu",
"action_bar_logo_alt": "ntfy logo",
"action_bar_unsubscribe": "Zrezygnuj z subskrypcji",
"notifications_attachment_copy_url_title": "Kopiuj adres URL załącznika do schowka",
"action_bar_settings": "Ustawienia",
"notifications_priority_x": "Priorytet {{priority}}",
"notifications_new_indicator": "Nowe powiadomienie",
"notifications_attachment_open_title": "Przejdź do {{url}}",
"notifications_click_copy_url_button": "Skopiuj łącze",
"notifications_none_for_topic_description": "Aby wysłać powiadomienia do tego tematu, wyślij PUT lub POST-Request na adres URL tematu.",
"notifications_none_for_any_title": "Nie otrzymałeś żadnych powiadomień.",
"notifications_more_details": "Bardziej szczegółowe informacje można znaleźć na <websiteLink>stronie internetowej</websiteLink> oraz w <docsLink>dokumentacji</docsLink>.",
"publish_dialog_priority_default": "Domyślny priorytet",
"publish_dialog_priority_max": "Max. priorytet",
"publish_dialog_priority_high": "Wysoki priorytet",
"publish_dialog_delay_placeholder": "Opóźnienie dostarczenie, np.{{unixTimestamp}}, {{relativeTime}}, lub \"{{naturalLanguage}}\" (tylko w języku angielskim)",
"subscribe_dialog_subscribe_button_subscribe": "Subskrybuj",
"prefs_users_table_user_header": "Użytkownik",
"publish_dialog_attached_file_remove": "Usuń załączony plik",
"subscribe_dialog_subscribe_description": "Tematy nie mogą być chronione hasłem, więc wybierz trudną do odgadnięcia nazwę. Po zasubskrybowaniu możesz wysyłać powiadomienia poprzez POST/PUT.",
"subscribe_dialog_login_title": "Wymagane jest zalogowanie się",
"prefs_notifications_delete_after_title": "Usuń powiadomienia",
"prefs_users_dialog_password_label": "Hasło",
"priority_low": "niski",
"priority_default": "podstawowy",
"priority_max": "maksymalny",
"prefs_notifications_delete_after_three_hours": "Po trzech godzinach",
"prefs_users_dialog_base_url_label": "Adres URL usługi, np. https://ntfy.sh",
"prefs_notifications_sound_no_sound": "Bez dzwięku",
"prefs_users_dialog_username_label": "Nazwa użytkownika, np. phil",
"priority_high": "wysoki",
"prefs_notifications_min_priority_max_only": "Tylko maksymalny priorytet",
"prefs_notifications_delete_after_never": "Nigdy",
"prefs_users_dialog_title_edit": "Edytuj użytkownika",
"priority_min": "minimum",
"error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>."
}

View File

@@ -150,5 +150,9 @@
"error_boundary_stack_trace": "Трассировка стека",
"error_boundary_gathering_info": "Соберите больше информации …",
"publish_dialog_drop_file_here": "Перетащите файл юда",
"prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше"
"prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше",
"action_bar_toggle_action_menu": "Открыть/закрыть меню",
"action_bar_show_menu": "Показать меню",
"action_bar_logo_alt": "ntfy лого",
"emoji_picker_search_clear": "Очистить поиск"
}

View File

@@ -0,0 +1,191 @@
{
"action_bar_logo_alt": "логотип ntfy",
"action_bar_settings": "Налаштування",
"message_bar_type_message": "Введіть повідомлення тут",
"message_bar_error_publishing": "Помилка публікації сповіщення",
"message_bar_show_dialog": "Показати діалогове вікно публікації",
"nav_topics_title": "Підписки на теми",
"nav_button_settings": "Налаштування",
"nav_button_documentation": "Документація",
"nav_button_subscribe": "Підписатися на тему",
"nav_button_muted": "Сповіщення вимкнено",
"nav_button_connecting": "підключення",
"alert_grant_title": "Сповіщення вимкнено",
"alert_grant_description": "Дозвольте браузеру показувати сповіщення.",
"alert_grant_button": "Дозволити",
"alert_not_supported_title": "Сповіщення не підтримуються",
"notifications_list_item": "Сповіщення",
"notifications_attachment_image": "Прикріплене зображення",
"notifications_attachment_open_title": "Перейти на {{url}}",
"notifications_attachment_open_button": "Відкрити вкладення",
"notifications_attachment_link_expires": "термін дії посилання закінчується {{date}}",
"notifications_actions_http_request_title": "Надіслати HTTP {{method}} на {{url}}",
"notifications_none_for_any_title": "Ви не отримали жодних сповіщень.",
"notifications_no_subscriptions_description": "Натисніть \"{{linktext}}\" посилання, щоб створити або підписатися на тему. Після цього ви зможете надсилати повідомлення за допомогою PUT або POST, і ви отримуватимете тут повідомлення.",
"notifications_more_details": "Додаткову інформацію можна знайти на <websiteLink>сайті</websiteLink> або в <docsLink>документації</docsLink>.",
"notifications_loading": "Завантаження сповіщень…",
"publish_dialog_title_topic": "Опублікувати в {{topic}}",
"publish_dialog_title_no_topic": "Опублікувати сповіщення",
"publish_dialog_progress_uploading": "Завантаження…",
"publish_dialog_message_published": "Сповіщення опубліковано",
"publish_dialog_attachment_limits_quota_reached": "перевищує квоту, залишилося {{remainingBytes}}",
"publish_dialog_priority_low": "Низький пріоритет",
"publish_dialog_topic_label": "Назва теми",
"publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts",
"publish_dialog_topic_reset": "Скинути тему",
"publish_dialog_title_label": "Заголовок",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад Сповіщення про дисковий простір",
"publish_dialog_message_label": "Повідомлення",
"publish_dialog_message_placeholder": "Введіть повідомлення",
"publish_dialog_tags_label": "Теги",
"publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад warning, srv1-backup",
"publish_dialog_click_placeholder": "URL-адреса, яка відкривається після натискання сповіщення",
"publish_dialog_email_label": "Електронна пошта",
"publish_dialog_attach_placeholder": "Прикріпіть файл за URL-адресою, наприклад https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Видалити URL вкладення",
"publish_dialog_filename_placeholder": "Ім'я файлу вкладення",
"publish_dialog_delay_reset": "Видалити затримку доставлення",
"publish_dialog_chip_click_label": "Адреса",
"publish_dialog_chip_email_label": "Переслати на електронну пошту",
"publish_dialog_chip_topic_label": "Змінити тему",
"publish_dialog_attached_file_remove": "Видалити прикріплений файл",
"subscribe_dialog_subscribe_topic_placeholder": "Назва теми, наприклад phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер",
"subscribe_dialog_subscribe_base_url_label": "URL служби",
"subscribe_dialog_login_password_label": "Пароль",
"subscribe_dialog_login_button_back": "Назад",
"subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований",
"prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні",
"prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}",
"prefs_notifications_min_priority_description_any": "Показати всі сповіщень, незалежно від пріоритету",
"prefs_notifications_min_priority_any": "Будь-який пріоритет",
"prefs_notifications_min_priority_default_and_higher": "Пріоритет за замовчуванням та високий",
"prefs_notifications_delete_after_title": "Видалити сповіщення",
"prefs_notifications_delete_after_never": "Ніколи",
"prefs_notifications_delete_after_one_day": "Через день",
"prefs_notifications_delete_after_one_week": "Через тиждень",
"prefs_notifications_delete_after_one_month": "Через місяць",
"prefs_notifications_delete_after_never_description": "Сповіщення ніколи не видаляються автоматично",
"prefs_notifications_delete_after_three_hours_description": "Сповіщення автоматично видаляються через три години",
"prefs_notifications_delete_after_one_day_description": "Сповіщення автоматично видаляються через один день",
"prefs_notifications_delete_after_one_week_description": "Сповіщення автоматично видаляються через тиждень",
"prefs_notifications_delete_after_one_month_description": "Сповіщення автоматично видаляються через місяць",
"prefs_users_title": "Керувати користувачами",
"prefs_users_table": "Таблиця користувачів",
"prefs_users_edit_button": "Редагувати користувача",
"prefs_users_dialog_button_save": "Зберегти",
"prefs_appearance_title": "Зовнішній вигляд",
"priority_default": "за замовчуванням",
"priority_high": "високий",
"priority_max": "макс",
"error_boundary_title": "Ой, ntfy впав",
"error_boundary_button_copy_stack_trace": "Копіювати трасування стека",
"action_bar_show_menu": "Показати меню",
"action_bar_toggle_action_menu": "Відкрити/закрити меню",
"action_bar_send_test_notification": "Надіслати тестове сповіщення",
"action_bar_clear_notifications": "Очистити всі сповіщення",
"action_bar_toggle_mute": "Вимкнути/увімкнути сповіщення",
"action_bar_unsubscribe": "Відписатися",
"message_bar_publish": "Опублікувати повідомлення",
"nav_button_all_notifications": "Усі сповіщення",
"alert_not_supported_description": "Ваш браузер не підтримує сповіщення.",
"notifications_list": "Список сповіщень",
"notifications_mark_read": "Позначити як прочитане",
"notifications_delete": "Видалити",
"notifications_tags": "Теги",
"nav_button_publish_message": "Опублікувати сповіщення",
"notifications_attachment_copy_url_title": "Копіювати URL-адресу вкладення",
"notifications_attachment_link_expired": "термін дії посилання для завантаження закінчився",
"publish_dialog_progress_uploading_detail": "Завантажується {{loaded}}/{{total}} ({{percent}}%) …",
"notifications_priority_x": "Пріоритет {{priority}}",
"notifications_attachment_copy_url_button": "Копіювати URL-адресу",
"notifications_copied_to_clipboard": "Скопійовано в буфер обміну",
"notifications_attachment_file_video": "відео файл",
"notifications_attachment_file_audio": "звуковий файл",
"publish_dialog_emoji_picker_show": "Виберіть емодзі",
"notifications_new_indicator": "Нове сповіщення",
"notifications_attachment_file_image": "файл зображення",
"notifications_attachment_file_document": "інший документ",
"notifications_click_copy_url_title": "Копіювати URL-адресу посилання",
"notifications_click_copy_url_button": "Копіювати посилання",
"notifications_actions_not_supported": "Дія не підтримується у браузері",
"notifications_attachment_file_app": "Файл програми Android",
"notifications_click_open_button": "Відкрити посилання",
"notifications_actions_open_url_title": "Перейти на {{url}}",
"notifications_none_for_topic_description": "Щоб надіслати сповіщення до цієї теми, просто надішліть PUT або POST на URL-адресу цієї теми.",
"notifications_no_subscriptions_title": "Схоже, у вас ще немає жодної підписки.",
"publish_dialog_drop_file_here": "Перетягніть файл сюди",
"notifications_none_for_topic_title": "Ви ще не отримували сповіщення на цю тему.",
"notifications_example": "Приклад",
"notifications_none_for_any_description": "Щоб надіслати сповіщення до теми, просто надішліть PUT або POST на URL-адресу теми. Ось приклад, використовуючи одну з ваших тем.",
"publish_dialog_attachment_limits_file_and_quota_reached": "перевищує {{fileSizeLimit}} розмір файлу, {{remainingBytes}} залишилося",
"publish_dialog_priority_default": "Пріоритет за замовчуванням",
"publish_dialog_attachment_limits_file_reached": "перевищує {{fileSizeLimit}} розмір файлу",
"publish_dialog_priority_min": "Мін. пріоритет",
"publish_dialog_priority_high": "Високий пріоритет",
"publish_dialog_priority_max": "Макс. пріоритет",
"publish_dialog_base_url_placeholder": "URL-адреса сервісу, наприклад https://example.com",
"publish_dialog_base_url_label": "URL служби",
"publish_dialog_other_features": "Інші можливості:",
"publish_dialog_chip_attach_file_label": "Прикріпити локальний файл",
"publish_dialog_priority_label": "Пріоритет",
"publish_dialog_click_label": "Натисніть URL",
"publish_dialog_click_reset": "Видалити URL-адресу для натискання",
"publish_dialog_email_placeholder": "Адреса для пересилання сповіщення, наприклад phil@example.com",
"publish_dialog_attach_label": "URL-адреса вкладення",
"publish_dialog_filename_label": "Ім'я файлу",
"publish_dialog_delay_label": "Затримка",
"publish_dialog_email_reset": "Видалити пересилання електронної пошти",
"publish_dialog_chip_attach_url_label": "Прикріпити файл за URL",
"publish_dialog_details_examples_description": "Приклади та докладний опис усіх функцій, зверніться до <docsLink>документації</docsLink>.",
"publish_dialog_button_cancel_sending": "Скасувати відправку",
"publish_dialog_attached_file_filename_placeholder": "Ім'я прикріпленого файлу",
"publish_dialog_delay_placeholder": "Затримка доставлення, наприклад {{unixTimestamp}}, {{relativeTime}} або \"{{naturalLanguage}}\" (лише англійською)",
"publish_dialog_button_send": "Надіслати",
"publish_dialog_checkbox_publish_another": "Опублікувати ще",
"publish_dialog_chip_delay_label": "Затримка доставлення",
"publish_dialog_button_cancel": "Скасувати",
"publish_dialog_attached_file_title": "Прикріплений файл:",
"subscribe_dialog_subscribe_description": "Теми можуть не бути захищені паролем, тому виберіть назву, яку нелегко вгадати. Після підписки ви можете PUT/POST сповіщення.",
"emoji_picker_search_placeholder": "Пошук емодзі",
"emoji_picker_search_clear": "Очистити пошук",
"subscribe_dialog_subscribe_title": "Підпишіться на тему",
"subscribe_dialog_login_username_label": "Ім'я користувача, наприклад phil",
"prefs_notifications_title": "Сповіщення",
"subscribe_dialog_subscribe_button_cancel": "Скасувати",
"subscribe_dialog_subscribe_button_subscribe": "Підписатися",
"subscribe_dialog_error_user_anonymous": "анонімний",
"subscribe_dialog_login_title": "Потрібна авторизація",
"subscribe_dialog_login_description": "Ця тема захищена паролем. Будь ласка, введіть ім'я користувача та пароль, щоб підписатися.",
"prefs_notifications_sound_title": "Звук сповіщення",
"subscribe_dialog_login_button_login": "Логін",
"prefs_notifications_sound_no_sound": "Без звука",
"prefs_notifications_sound_play": "Відтворення вибраного звуку",
"prefs_users_description": "Додайте/видаляйте користувачів для захищених тем. Зверніть увагу, що ім'я користувача та пароль зберігаються у локальному сховищі браузера.",
"prefs_notifications_min_priority_title": "Мінімальний пріоритет",
"prefs_notifications_min_priority_high_and_higher": "Високий пріоритет і вище",
"prefs_notifications_min_priority_description_x_or_higher": "Показувати сповіщення, якщо пріоритет {{number}} ({{name}}) або вище",
"prefs_notifications_min_priority_description_max": "Показувати сповіщення, якщо пріоритет 5 (макс.)",
"prefs_notifications_min_priority_low_and_higher": "Низький та високий пріоритет",
"prefs_notifications_min_priority_max_only": "Тільки максимальний пріоритет",
"prefs_users_table_base_url_header": "URL служби",
"prefs_users_dialog_password_label": "Пароль",
"prefs_notifications_delete_after_three_hours": "Через три години",
"prefs_users_add_button": "Додати користувача",
"prefs_users_dialog_title_edit": "Редагувати користувача",
"prefs_users_dialog_base_url_label": "URL-адреса служби, наприклад https://ntfy.sh",
"prefs_users_delete_button": "Видалити користувача",
"prefs_users_table_user_header": "Користувач",
"prefs_users_dialog_title_add": "Додати користувача",
"prefs_users_dialog_username_label": "Ім'я користувача, наприклад phil",
"prefs_users_dialog_button_cancel": "Скасувати",
"prefs_users_dialog_button_add": "Додати",
"prefs_appearance_language_title": "Мова",
"error_boundary_gathering_info": "Зберіть більше інформації…",
"priority_min": "мін",
"error_boundary_description": "Очевидно, цього не повинно статися. Дуже шкода.<br/>Якщо у вас є хвилина, <githubLink>повідомте про це на GitHub</githubLink> або повідомте нам через <discordLink>Discord</discordLink> або <matrixLink>Matrix</matrixLink> .",
"priority_low": "низький",
"error_boundary_stack_trace": "Трасування стека",
"error_boundary_unsupported_indexeddb_title": "Приватний перегляд не підтримується",
"error_boundary_unsupported_indexeddb_description": "Веб-програма ntfy потребує IndexedDB для роботи, а ваш браузер не підтримує IndexedDB у режимі приватного перегляду.<br/><br/>На жаль, використання ntfy web не має сенсу у режимі приватного перегляду, оскільки все зберігається в пам’яті браузера. Ви можете прочитати більше про це <githubLink>у цьому випуску GitHub</githubLink> або поспілкуватися з нами на <discordLink>Discord</discordLink> або <matrixLink>Matrix</matrixLink>."
}

View File

@@ -80,7 +80,7 @@
"publish_dialog_delay_label": "延期",
"publish_dialog_other_features": "其它功能:",
"publish_dialog_attach_placeholder": "使用链接地址附加文件,例如 https://f-droid.org/F-Droid.apk",
"publish_dialog_delay_reset": "删除延迟交付",
"publish_dialog_delay_reset": "删除延期投递",
"publish_dialog_attach_reset": "移除附件链接地址",
"publish_dialog_chip_click_label": "点击链接地址",
"publish_dialog_chip_email_label": "转发邮件",
@@ -95,7 +95,7 @@
"emoji_picker_search_placeholder": "查找表情符号",
"emoji_picker_search_clear": "清除搜索",
"subscribe_dialog_subscribe_title": "订阅主题",
"publish_dialog_chip_delay_label": "延迟交付",
"publish_dialog_chip_delay_label": "延期投递",
"publish_dialog_chip_attach_url_label": "链接附件地址",
"subscribe_dialog_subscribe_use_another_label": "使用其他服务器",
"subscribe_dialog_subscribe_button_subscribe": "订阅",
@@ -118,8 +118,8 @@
"prefs_notifications_min_priority_description_max": "仅显示最高优先级的通知",
"prefs_notifications_min_priority_any": "任意优先级",
"prefs_notifications_min_priority_low_and_higher": "低优先级和更高优先级",
"prefs_notifications_min_priority_default_and_higher": "默认优先级更高优先级",
"prefs_notifications_min_priority_high_and_higher": "高优先级更高优先级",
"prefs_notifications_min_priority_default_and_higher": "默认优先级更高优先级",
"prefs_notifications_min_priority_high_and_higher": "高优先级更高优先级",
"prefs_notifications_min_priority_max_only": "仅最高优先级",
"prefs_notifications_delete_after_never": "从不",
"prefs_notifications_delete_after_one_month": "一月后",
@@ -186,6 +186,6 @@
"prefs_users_edit_button": "编辑用户",
"publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup",
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易猜测的名字。订阅后,您可以使用 PUT/POST 通知。",
"publish_dialog_delay_placeholder": "延迟交付,例如{{unixTimestamp}}、{{relativeTime}}或{{naturalLanguage}}(仅限英语)"
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。",
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或{{naturalLanguage}}(仅限英语)"
}

View File

@@ -0,0 +1,56 @@
{
"action_bar_logo_alt": "ntfy 標識",
"action_bar_unsubscribe": "取消訂閱",
"action_bar_toggle_mute": "通知靜音/解除通知靜音",
"action_bar_toggle_action_menu": "開啟/關閉操作選單",
"message_bar_type_message": "在這邊輸入訊息",
"alert_grant_description": "允許瀏覽器權限以顯示桌面通知。",
"alert_grant_button": "允許",
"notifications_list": "通知清單",
"notifications_list_item": "通知",
"notifications_mark_read": "標示已讀",
"notifications_attachment_image": "附加圖片",
"notifications_attachment_copy_url_title": "複製附件URL到剪貼板",
"notifications_attachment_copy_url_button": "複製URL",
"notifications_attachment_open_title": "前往 {{url}}",
"notifications_attachment_open_button": "開啟附件",
"notifications_attachment_link_expired": "下載連結已過期",
"notifications_attachment_file_video": "影片檔案",
"notifications_attachment_file_app": "Android 應用程式檔案",
"notifications_attachment_file_document": "其他文件",
"notifications_click_copy_url_title": "複製連結URL到剪貼板",
"notifications_click_copy_url_button": "複製連結",
"notifications_click_open_button": "開啟連結",
"notifications_actions_not_supported": "網頁程式無法支援該動作",
"notifications_actions_http_request_title": "傳送 HTTP {{method}} 到 {{url}}",
"notifications_none_for_topic_title": "尚未收到任何此主題的通知。",
"notifications_none_for_topic_description": "如要寄送通知到此主題,請使用 PUT 或 POST 到此主題URL。",
"notifications_none_for_any_title": "尚未收到任何通知。",
"action_bar_settings": "設定",
"action_bar_send_test_notification": "寄送測試通知",
"action_bar_clear_notifications": "清除所有通知",
"action_bar_show_menu": "顯示選單",
"nav_button_documentation": "文件",
"nav_button_publish_message": "發布通知",
"nav_button_muted": "通知已靜音",
"notifications_copied_to_clipboard": "複製到剪貼板",
"message_bar_publish": "發布訊息",
"message_bar_show_dialog": "顯示發布對話筐",
"message_bar_error_publishing": "無法發布通知",
"nav_topics_title": "訂閱主題",
"nav_button_all_notifications": "所有通知",
"nav_button_settings": "設定",
"nav_button_subscribe": "訂閱主題",
"nav_button_connecting": "連線中",
"alert_grant_title": "通知已關閉",
"alert_not_supported_title": "不支援通知",
"alert_not_supported_description": "瀏覽器不支援通知。",
"notifications_tags": "標籤",
"notifications_priority_x": "優先度 {{priority}}",
"notifications_new_indicator": "新通知",
"notifications_attachment_file_audio": "聲音檔案",
"notifications_delete": "刪除",
"notifications_attachment_link_expires": "連結已過期 {{date}}",
"notifications_attachment_file_image": "圖片檔案",
"notifications_actions_open_url_title": "前往 {{url}}"
}

View File

@@ -1,13 +1,12 @@
import {
basicAuth,
encodeBase64,
fetchLinesIterator,
maybeWithBasicAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince, userStatsUrl
topicUrlJsonPollWithSince,
userStatsUrl
} from "./utils";
import userManager from "./UserManager";

View File

@@ -1,4 +1,4 @@
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicShortUrl} from "./utils";
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import logo from "../img/ntfy.png";
@@ -18,8 +18,9 @@ class Notifier {
return;
}
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription);
const message = formatMessage(notification);
const title = formatTitleWithDefault(notification, shortUrl);
const title = formatTitleWithDefault(notification, displayName);
// Show notification
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
@@ -74,8 +75,22 @@ class Notifier {
}
supported() {
return this.browserSupported() && this.contextSupported();
}
browserSupported() {
return 'Notification' in window;
}
/**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
*/
contextSupported() {
return location.protocol === 'https:'
|| location.hostname.match('^127.')
|| location.hostname === 'localhost';
}
}
const notifier = new Notifier();

View File

@@ -133,6 +133,12 @@ class SubscriptionManager {
});
}
async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, {
displayName: displayName
});
}
async pruneNotifications(thresholdTimestamp) {
await db.notifications
.where("time").below(thresholdTimestamp)

View File

@@ -38,6 +38,15 @@ export const disallowedTopic = (topic) => {
return config.disallowedTopics.includes(topic);
}
export const topicDisplayName = (subscription) => {
if (subscription.displayName) {
return subscription.displayName;
} else if (subscription.baseUrl === window.location.origin) {
return subscription.topic;
}
return topicShortUrl(subscription.baseUrl, subscription.topic);
};
// Format emojis (see emoji.js)
const emojis = {};
rawEmojis.forEach(emoji => {

View File

@@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography";
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import Box from "@mui/material/Box";
import {formatShortDateTime, shuffle, topicShortUrl} from "../app/utils";
import {formatShortDateTime, shuffle, topicDisplayName, topicShortUrl} from "../app/utils";
import {useLocation, useNavigate} from "react-router-dom";
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Grow from '@mui/material/Grow';
@@ -24,13 +24,14 @@ import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next";
import {Portal, Snackbar} from "@mui/material";
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
const ActionBar = (props) => {
const { t } = useTranslation();
const location = useLocation();
let title = "ntfy";
if (props.selected) {
title = topicShortUrl(props.selected.baseUrl, props.selected.topic);
title = topicDisplayName(props.selected);
} else if (location.pathname === "/settings") {
title = t("action_bar_settings");
}
@@ -79,6 +80,7 @@ const SettingsIcons = (props) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
const anchorRef = useRef(null);
const subscription = props.subscription;
@@ -116,6 +118,10 @@ const SettingsIcons = (props) => {
}
};
const handleSubscriptionSettings = async () => {
setSubscriptionSettingsOpen(true);
}
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
@@ -201,6 +207,7 @@ const SettingsIcons = (props) => {
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
<MenuItem onClick={handleSubscriptionSettings}>{t("action_bar_subscription_settings")}</MenuItem>
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
@@ -218,6 +225,14 @@ const SettingsIcons = (props) => {
message={t("message_bar_error_publishing")}
/>
</Portal>
<Portal>
<SubscriptionSettingsDialog
key={`subscriptionSettingsDialog${subscription.id}`}
open={subscriptionSettingsOpen}
subscription={subscription}
onClose={() => setSubscriptionSettingsOpen(false)}
/>
</Portal>
</>
);
};

View File

@@ -11,10 +11,10 @@ import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add";
import SubscribeDialog from "./SubscribeDialog";
import {Alert, AlertTitle, Badge, CircularProgress, ListSubheader} from "@mui/material";
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader} from "@mui/material";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import {openUrl, topicShortUrl, topicUrl} from "../app/utils";
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
@@ -24,7 +24,7 @@ import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
import ArticleIcon from '@mui/icons-material/Article';
import {useTranslation} from "react-i18next";
import {Trans, useTranslation} from "react-i18next";
const navWidth = 280;
@@ -91,14 +91,17 @@ const NavList = (props) => {
};
const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationNotSupportedBox = !notifier.supported();
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : '';
return (
<>
<Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
<List component="nav" sx={{ paddingTop: (showNotificationGrantBox || showNotificationNotSupportedBox) ? '0' : '' }}>
{showNotificationNotSupportedBox && <NotificationNotSupportedAlert/>}
<List component="nav" sx={{ paddingTop: navListPadding }}>
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
{!showSubscriptionsList &&
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
@@ -170,12 +173,10 @@ const SubscriptionItem = (props) => {
const icon = (subscription.state === ConnectionState.Connecting)
? <CircularProgress size="24px"/>
: <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
const label = (subscription.baseUrl === window.location.origin)
? subscription.topic
: topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
? `${label} (${t("nav_button_connecting")})`
: label;
? `${displayName} (${t("nav_button_connecting")})`
: displayName;
const handleClick = async () => {
navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id);
@@ -183,7 +184,7 @@ const SubscriptionItem = (props) => {
return (
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={label}/>
<ListItemText primary={displayName}/>
{subscription.mutedUntil > 0 &&
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
</ListItemButton>
@@ -211,7 +212,7 @@ const NotificationGrantAlert = (props) => {
);
};
const NotificationNotSupportedAlert = () => {
const NotificationBrowserNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
@@ -224,4 +225,24 @@ const NotificationNotSupportedAlert = () => {
);
};
const NotificationContextNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>
<Trans
i18nKey="alert_not_supported_context_description"
components={{
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/>
}}
/>
</Typography>
</Alert>
<Divider/>
</>
);
};
export default Navigation;

View File

@@ -436,7 +436,7 @@ const Appearance = () => {
const Language = () => {
const { t, i18n } = useTranslation();
const labelId = "prefLanguage";
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
const lang = i18n.language ?? "en";
@@ -461,7 +461,9 @@ const Language = () => {
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nl">Nederlands</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
<MenuItem value="uk">Українська</MenuItem>
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
<MenuItem value="pl">Polski</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem>
</Select>

View File

@@ -0,0 +1,59 @@
import * as React from 'react';
import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
import theme from "./theme";
import api from "../app/Api";
import {topicUrl, validTopic, validUrl} from "../app/utils";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
const SubscriptionSettingsDialog = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const handleSave = async () => {
await subscriptionManager.setDisplayName(subscription.id, displayName);
props.onClose();
}
return (
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("subscription_settings_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("subscription_settings_dialog_description")}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="topic"
placeholder={t("subscription_settings_dialog_display_name_placeholder")}
value={displayName}
onChange={ev => setDisplayName(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscription_settings_dialog_display_name_placeholder")
}}
/>
</DialogContent>
<DialogFooter>
<Button onClick={props.onClose}>{t("subscription_settings_button_cancel")}</Button>
<Button onClick={handleSave}>{t("subscription_settings_button_save")}</Button>
</DialogFooter>
</Dialog>
);
};
export default SubscriptionSettingsDialog;

Binary file not shown.

Binary file not shown.