Compare commits

...

297 Commits

Author SHA1 Message Date
binwiederhier
64b3c3c2fa Bump version 2023-03-01 11:46:32 -05:00
binwiederhier
983afb2b45 Fix some iffy tests with waitFor function 2023-03-01 11:36:48 -05:00
binwiederhier
cd3429842b Refine release notes 2023-02-28 15:34:46 -05:00
binwiederhier
d89df315e4 Bump deps 2023-02-28 14:40:26 -05:00
binwiederhier
fe3a225f8f Add billing-contact config option 2023-02-28 14:38:31 -05:00
binwiederhier
f862341997 Fix test, release notes 2023-02-28 11:57:49 -05:00
binwiederhier
8ca08ce868 Fix panic when using Firebase without users 2023-02-27 22:07:22 -05:00
binwiederhier
ba46630138 Various things 2023-02-27 21:13:15 -05:00
binwiederhier
a3087047b6 Enhance some duration flags 2023-02-27 14:34:05 -05:00
binwiederhier
217ca81b17 Remove broken test, replace with simpler one 2023-02-27 14:07:06 -05:00
binwiederhier
7edcebad1f Give test more time 2023-02-27 11:06:03 -05:00
binwiederhier
0af3e29ce1 Allow multiple log-level-overrides on the same field 2023-02-27 11:03:21 -05:00
binwiederhier
dd6462de13 Release notes 2023-02-27 10:49:18 -05:00
binwiederhier
52f18d048c Typo 2023-02-27 10:46:48 -05:00
binwiederhier
c522ee1dd8 Merge branch 'main' of github.com:binwiederhier/ntfy 2023-02-27 10:45:04 -05:00
binwiederhier
33e3f7ae46 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-27 10:44:58 -05:00
Philipp C. Heckel
87f9f88e32 Merge pull request #640 from Andersbiha/fix-635
Remove health check from dockerfile & document health check endpoint
2023-02-27 10:44:29 -05:00
Anders H
0fe1e109ed Added translation using Weblate (Danish) 2023-02-27 16:31:34 +01:00
binwiederhier
90b04417cf Thank you @soonoo for your donation 2023-02-27 09:38:44 -05:00
Anders B. Hansen
221004af39 docs: Add documentation for health check API endpoint 2023-02-27 15:05:03 +01:00
Anders B. Hansen
c3f6077f95 docs: Add optional health check to docker-compose config example 2023-02-27 15:04:43 +01:00
Anders B. Hansen
4f9227f100 docker: Revert health check addition from #555 2023-02-27 15:04:20 +01:00
109247019824
ae6f649a06 Translated using Weblate (Bulgarian)
Currently translated at 67.2% (230 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2023-02-27 07:36:51 +01:00
binwiederhier
26f9eddfc4 Thank you @0xAF for your donation 2023-02-26 21:13:26 -05:00
binwiederhier
00879d11d3 Upgrade dialog: Disable submit button for free tier 2023-02-25 22:24:04 -05:00
binwiederhier
f1bcc26cfe Bump deps 2023-02-25 21:20:58 -05:00
binwiederhier
0967414f79 Bump version, add more details to rate_visitor logs 2023-02-25 21:09:10 -05:00
binwiederhier
f4772b0c75 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-25 20:29:19 -05:00
binwiederhier
8215b66db3 Logging improvements, etc. 2023-02-25 20:23:22 -05:00
Poesty Li
d0a98afc49 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (342 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2023-02-26 00:39:48 +01:00
Rogelio Dominguez
da3a5681d9 Translated using Weblate (Spanish)
Currently translated at 70.4% (241 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2023-02-26 00:39:48 +01:00
binwiederhier
f7f343fe55 Logging fixes 2023-02-25 15:31:12 -05:00
binwiederhier
0606fbe60a Adjust Matrix/UP behavior to work with Synapse+Mastodon 2023-02-25 15:12:03 -05:00
binwiederhier
b2bedafae7 Merge branch 'vrate' of github.com:binwiederhier/ntfy into vrate 2023-02-25 09:41:57 -05:00
binwiederhier
c108e8d856 Merge branch 'main' of github.com:binwiederhier/ntfy into vrate 2023-02-25 09:41:50 -05:00
Philipp C. Heckel
5b5509d07c Merge pull request #637 from karmanyaahm/vrate
Subscriber Rate Limiting Error Handling
2023-02-25 09:41:08 -05:00
Karmanyaah Malhotra
0d7aba9487 Fix Matrix errors and tests 2023-02-25 00:12:14 -06:00
Karmanyaah Malhotra
fbbfa2bbc1 fix matrix tests for new error handling
Test driven development
2023-02-24 23:09:21 -06:00
Karmanyaah Malhotra
2f5cfab01c Fix 507 tests for UnifiedPush subscribe rate limiting 2023-02-24 22:16:03 -06:00
binwiederhier
70cd267ff5 Return 507 for UP publishers without subscribers 2023-02-24 22:07:18 -05:00
binwiederhier
d5052d79e6 Add up* length requirement 2023-02-24 21:10:41 -05:00
Philipp C. Heckel
a372eb99b7 Merge pull request #636 from jack828/jack828-typo
Fix typo - broadcasst -> broadcast
2023-02-24 19:15:48 -05:00
Jack Burgess
199933b752 Fix typo - broadcasst -> broadcast 2023-02-24 23:54:53 +00:00
binwiederhier
45928ddc47 Release notes 2023-02-24 15:11:59 -05:00
binwiederhier
bfc3983d06 Only set rate visitor if allowed 2023-02-24 14:45:30 -05:00
binwiederhier
2329695a47 Polishing 2023-02-23 20:46:53 -05:00
Rycoh
ab1dbb04bd Translated using Weblate (Romanian)
Currently translated at 3.2% (11 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ro/
2023-02-23 22:37:17 +01:00
Nifou
1fe19e41fb Translated using Weblate (French)
Currently translated at 100.0% (342 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2023-02-23 22:37:17 +01:00
Vri 🌈
a47ac2a5b5 Translated using Weblate (German)
Currently translated at 100.0% (342 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2023-02-23 22:37:16 +01:00
binwiederhier
8eae44ea61 Topic expiry attempt 2023-02-23 16:03:40 -05:00
binwiederhier
57e1104afb Ensure we return 429s for Matrix endpoints too; return proper error codes 2023-02-23 15:38:45 -05:00
binwiederhier
ede957973b Merge branch 'main' into vrate 2023-02-23 14:03:11 -05:00
binwiederhier
697c09e146 Release notes 2023-02-23 14:02:58 -05:00
binwiederhier
ab59d81d08 Release notes 2023-02-23 11:42:22 -05:00
Philipp C. Heckel
c8d3b665f5 Merge pull request #631 from tamcore/docs/examples-traccar
docs: add traccar example
2023-02-23 11:38:18 -05:00
binwiederhier
422ad0cc5d UnifiedPush: Treat non-Basic/Bearer Authorization header like header was not sent 2023-02-23 10:15:57 -05:00
binwiederhier
0c3d832c5f More todos 2023-02-23 09:38:53 -05:00
binwiederhier
483410c4a2 More tests; Discovered a bug with the response codes 2023-02-22 22:44:48 -05:00
binwiederhier
bdeec4d297 Polish a little 2023-02-22 22:26:43 -05:00
binwiederhier
21b27b5dbe Working test 2023-02-22 21:33:18 -05:00
binwiederhier
29340e7e24 Add test, fails 2023-02-22 21:00:56 -05:00
binwiederhier
4ab450309f Merge branch 'main' into user-account 2023-02-22 19:22:47 -05:00
binwiederhier
2ac63c4327 Disable Stripe telemetry 2023-02-22 15:49:51 -05:00
Philipp Born
c31b9236a1 docs: add traccar example 2023-02-22 21:41:18 +01:00
binwiederhier
1da4187405 "save up to" in upgrade dialog 2023-02-22 14:21:23 -05:00
binwiederhier
41282e2c73 Thank you @caseodilla for your sponsorship 2023-02-22 11:47:12 -05:00
binwiederhier
3d40acc26b Chip 2023-02-22 09:25:56 -05:00
Nifou
f7ed0eb4e7 Translated using Weblate (French)
Currently translated at 59.0% (202 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2023-02-22 10:38:35 +01:00
waclaw66
9eadaf4c3a Translated using Weblate (Czech)
Currently translated at 100.0% (342 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/
2023-02-22 08:36:02 +01:00
Karmanyaah Malhotra
ce7d447f16 limitRequestsWithTopic 2023-02-21 22:40:15 -06:00
binwiederhier
ef9d6d9f6c Support for annual billing intervals 2023-02-21 22:44:30 -05:00
Karmanyaah Malhotra
0e4044b747 rename lastVisitor to vRate 2023-02-21 20:18:04 -06:00
Karmanyaah Malhotra
bc3d897d7a Use mutexes in topic 2023-02-21 20:16:03 -06:00
Karmanyaah Malhotra
1655f584f9 rate limiting impl 2.0? 2023-02-21 20:04:56 -06:00
binwiederhier
07afaf961d Thank you @hansbickhofe for your sponsorship 2023-02-21 09:03:21 -05:00
binwiederhier
2b2a1eca9c Merge branch 'main' of github.com:binwiederhier/ntfy 2023-02-21 08:00:05 -05:00
binwiederhier
3dd964f42c Add Cloudron 2023-02-21 07:59:52 -05:00
Philipp C. Heckel
44aa7f4053 Merge pull request #626 from MichelMichels/docs-library-nlog-target
Add nlog-ntfy integration to docs
2023-02-21 06:33:40 -05:00
MichelMichels
965fc2016d Add nlog-ntfy integration to docs 2023-02-21 10:49:20 +01:00
binwiederhier
fd470702ab Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-20 21:51:18 -05:00
ButterflyOfFire
d17d86da95 Translated using Weblate (Arabic)
Currently translated at 80.7% (276 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ar/
2023-02-21 03:51:11 +01:00
Tmpod
f8a70c6025 Translated using Weblate (Portuguese)
Currently translated at 63.1% (216 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2023-02-21 03:51:10 +01:00
Sirius Chan
587cc48b24 Translated using Weblate (Chinese (Traditional))
Currently translated at 58.1% (199 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/
2023-02-21 03:51:09 +01:00
Ruben
0c430c37bc Translated using Weblate (Dutch)
Currently translated at 72.8% (249 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nl/
2023-02-21 03:51:09 +01:00
Tomáš Plášek
273b911ccf Translated using Weblate (Czech)
Currently translated at 63.7% (218 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/
2023-02-21 03:51:08 +01:00
Shoshin Akamine
a51228b374 Translated using Weblate (Japanese)
Currently translated at 64.6% (221 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2023-02-21 03:51:08 +01:00
Linerly
568b336913 Translated using Weblate (Indonesian)
Currently translated at 100.0% (342 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2023-02-21 03:51:07 +01:00
slundi
ab5fc36fb7 Translated using Weblate (French)
Currently translated at 58.4% (200 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2023-02-21 03:51:06 +01:00
Alejandro AR
ff78ecc195 Translated using Weblate (Spanish)
Currently translated at 63.4% (217 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2023-02-21 03:51:06 +01:00
109247019824
bf2acbf617 Translated using Weblate (Bulgarian)
Currently translated at 64.0% (219 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2023-02-21 03:51:05 +01:00
MrZander
f18b98d75b Translated using Weblate (Norwegian Bokmål)
Currently translated at 56.1% (192 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nb_NO/
2023-02-21 03:51:05 +01:00
Oğuz Ersen
16c5c74923 Translated using Weblate (Turkish)
Currently translated at 100.0% (342 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2023-02-21 03:51:05 +01:00
Christian Meis
3586fc90ca Translated using Weblate (German)
Currently translated at 100.0% (342 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2023-02-21 03:51:04 +01:00
binwiederhier
67b45455b8 Do not panic when changing tiers, and user is nil 2023-02-20 21:46:25 -05:00
binwiederhier
d92d1ad974 Blog post 2023-02-20 21:03:50 -05:00
binwiederhier
0177016fbc Do not disable "Reserve topic" checkbox for admins 2023-02-20 20:06:49 -05:00
Karmanyaah Malhotra
36685e9df9 Suggested changes
- b9badee6db (r1111115151)
- b9badee6db (r1111114771)
2023-02-20 17:58:51 -06:00
binwiederhier
61f403bff4 Email publishing with access tokens, release notes 2023-02-20 15:55:48 -05:00
binwiederhier
83d7dd99e8 Fix comments 2023-02-20 15:48:34 -05:00
binwiederhier
224eae2d2d Merge branch 'main' of github.com:binwiederhier/ntfy 2023-02-20 15:47:14 -05:00
binwiederhier
cf6997797e Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-20 15:47:09 -05:00
Philipp C. Heckel
33e75375fd Merge pull request #621 from tamcore/feature/email-with-access-control
Make email publishing work, when access-control is enabled
2023-02-20 15:47:05 -05:00
binwiederhier
b0540c1162 Blog posts 2023-02-20 15:45:11 -05:00
binwiederhier
4093a8ea5b Add sponsorship bar to docs 2023-02-20 09:19:51 -05:00
Philipp Born
e892b994c3 add support to pass access-token for e-mail publishing 2023-02-20 12:45:43 +01:00
binwiederhier
5f75e98861 Parse nested multipart emails, fixes #610 2023-02-19 10:13:25 -05:00
binwiederhier
e9b05e8ed7 Support for base64 encoded emails 2023-02-19 09:39:04 -05:00
binwiederhier
1edcc239e5 Thank you @KucharczykL for your sponsorship 2023-02-19 09:07:53 -05:00
binwiederhier
61d09cf033 Release log 2023-02-19 09:07:44 -05:00
Linerly
227ea8ecc5 Translated using Weblate (Indonesian)
Currently translated at 64.9% (222 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2023-02-19 13:52:28 +01:00
binwiederhier
7e4fb3caed Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-19 07:25:20 -05:00
binwiederhier
152dfbbb54 Add Arabic 2023-02-19 07:25:14 -05:00
ButterflyOfFire
c3f29bdc41 Translated using Weblate (Arabic)
Currently translated at 83.0% (157 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ar/
2023-02-19 13:22:41 +01:00
binwiederhier
fb727fc84a Derp 2023-02-18 19:54:47 -05:00
binwiederhier
9377c265a8 Thank you @oakd for your sponsorship 2023-02-18 19:49:29 -05:00
binwiederhier
59b59fda98 Merge branch 'main' of github.com:binwiederhier/ntfy 2023-02-18 19:48:46 -05:00
binwiederhier
96439ac41f Do not set m.Expires if cache: no is set 2023-02-18 19:48:21 -05:00
Philipp C. Heckel
c9a5d00b89 Merge pull request #618 from KucharczykL/patch-1
Fix typo in publish.md
2023-02-18 07:40:02 -05:00
Lukáš Kucharczyk
9efc1ec4f6 Fix typo in publishmd 2023-02-18 12:30:10 +01:00
binwiederhier
85fc16b016 Bump deps 2023-02-17 21:47:14 -05:00
binwiederhier
5287fa1c94 Bump version 2023-02-17 21:35:27 -05:00
Philipp C. Heckel
1c54be3581 Merge pull request #612 from ntomita/patch-1
Update README.md
2023-02-17 21:33:34 -05:00
binwiederhier
484fd91452 Add comment 2023-02-17 21:00:43 -05:00
binwiederhier
9ff3bb0c87 Ensure that calls to standard logger log.Println also output JSON 2023-02-17 20:52:48 -05:00
binwiederhier
38e7801b41 Fix panic in manager when attachment-cache-dir is not set, fixes #617 2023-02-17 15:56:48 -05:00
binwiederhier
7fb6f794e5 Merge branch 'main' of github.com:binwiederhier/ntfy 2023-02-17 08:14:15 -05:00
binwiederhier
df68b0cb43 Blog post 2023-02-17 08:13:50 -05:00
Philipp C. Heckel
ca49fd1161 Merge pull request #613 from danroc/main
Fix login, signup and reservation environment variables in documentation
2023-02-17 06:47:29 -05:00
Philipp C. Heckel
bb3f17ada2 Merge pull request #614 from academo/academo/add-grafana-ntfy-integration
Add integration for Grafana Alerting webhook
2023-02-17 06:46:47 -05:00
Esteban Beltran
d18c61f0da Add integration for Grafana Alerting webhook 2023-02-17 12:42:32 +01:00
Daniel Rocha
92cfc04024 Fix login, signup and reservation environment variables 2023-02-17 10:53:09 +01:00
binwiederhier
2d0ce79011 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-16 22:48:49 -05:00
ButterflyOfFire
c6e091a754 Translated using Weblate (Arabic)
Currently translated at 22.7% (43 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ar/
2023-02-16 23:39:34 +01:00
binwiederhier
c8c16eb8e6 Fix failing test 2023-02-16 16:32:43 -05:00
Naofumi Tomita
0e1082b09c Update README.md
Italicize "ntfy" for emphasis, which was dropped during making changes.
2023-02-16 16:17:57 -05:00
binwiederhier
c815b183d4 Bump release notes 2023-02-16 16:14:41 -05:00
Naofumi Tomita
a95d1f9200 Update README.md
Making the description of the repo clearer and more objective.
2023-02-16 16:12:44 -05:00
binwiederhier
b8e976f4f6 Bump to 2.0.0 2023-02-16 14:21:19 -05:00
binwiederhier
6c51b7558a Fine tuning error messages, add --ignore-exists flag to tier/user command 2023-02-16 10:35:23 -05:00
binwiederhier
c4e4cc5aa7 Tiny release notes fix 2023-02-15 19:55:03 -05:00
binwiederhier
5e90ff7db0 Docs drop shadow in dark mode 2023-02-15 19:52:03 -05:00
ButterflyOfFire
6451762508 Added translation using Weblate (Arabic) 2023-02-15 22:44:07 +01:00
binwiederhier
fda90c217f Bump 2023-02-15 15:41:41 -05:00
binwiederhier
94066c24dc Docs docs docs 2023-02-15 15:39:01 -05:00
binwiederhier
76d46ec646 Minor tweaks 2023-02-15 10:55:01 -05:00
Karmanyaah Malhotra
b9badee6db remove TTL, will make a seperate PR 2023-02-15 03:38:24 -06:00
Karmanyaah Malhotra
c6b64df662 remove ttl 2023-02-15 03:31:59 -06:00
binwiederhier
e90f52f375 Merge branch 'main' into user-account 2023-02-14 23:24:41 -05:00
binwiederhier
ca68494203 Forum posts 2023-02-14 23:22:03 -05:00
binwiederhier
396e61cdb3 Bump go build version in CI 2023-02-14 22:00:04 -05:00
binwiederhier
dfaab8c386 Bump version 2023-02-14 21:45:03 -05:00
binwiederhier
0df3e3e4f5 Merge branch 'main' into user-account 2023-02-14 21:22:46 -05:00
binwiederhier
f2f5a06be1 Bump JS deps 2023-02-14 20:58:29 -05:00
binwiederhier
8d7ff4d7db SMTP server tests 2023-02-14 20:56:02 -05:00
Karmanyaah Malhotra
7c5b9c0e62 only log expiry if applicable 2023-02-14 14:21:33 -06:00
Karmanyaah Malhotra
6bfe4a9779 Bill to visitor and set TTL in response 2023-02-14 14:07:02 -06:00
Karmanyaah Malhotra
fb2fa4c478 Fix m.Expires and prune stale topics based on lastVisitorExpires 2023-02-14 14:00:43 -06:00
Karmanyaah Malhotra
28b654ae27 Keep track of lastVisitor to a topic 2023-02-14 13:58:13 -06:00
binwiederhier
9f052bdf8b Merge branch 'main' into smtp-lib-upgrade 2023-02-14 14:44:09 -05:00
binwiederhier
5472c8513f Release notes 2023-02-14 14:40:41 -05:00
binwiederhier
c028ec9083 Merge branch 'patch-1' 2023-02-14 14:39:34 -05:00
binwiederhier
31a87935a5 Refine iOS docs 2023-02-14 14:39:22 -05:00
binwiederhier
80292f1f4d Tiny changes 2023-02-14 14:26:30 -05:00
Karmanyaah Malhotra
d686e1ee77 Use visitor instead of UserID in topicSubscription 2023-02-14 13:07:32 -06:00
binwiederhier
66cf54e458 Fix delayed messages expiry, thanks to @karmanyaahm 2023-02-14 14:05:41 -05:00
binwiederhier
610adb062b More docs 2023-02-14 13:58:49 -05:00
binwiederhier
70aa384bc3 Docs for access tokens 2023-02-13 21:35:58 -05:00
binwiederhier
355424c0da Fix trace logging 2023-02-13 13:20:05 -05:00
binwiederhier
9b118e8085 Merge branch 'main' into user-account 2023-02-12 21:14:50 -05:00
binwiederhier
9e20ee35e1 Thanks to @overtone1000 and @Joachim256 for your sponsorship and donation 2023-02-12 21:13:26 -05:00
binwiederhier
0d4ef18358 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-02-12 21:11:16 -05:00
SticksDev
8bde80a3d2 Add iOS docs to the dev docs
Imports old dev docs
Also adds my currently open PR #10 on the docs to improve them.
2023-02-12 21:08:37 -05:00
binwiederhier
bed60b71ff Tester feedback 2023-02-12 21:05:24 -05:00
binwiederhier
cc309e87e9 Remove awkward subscription id 2023-02-12 14:09:44 -05:00
binwiederhier
9131d3d521 Token tests 2023-02-12 12:19:46 -05:00
binwiederhier
6b4971786f Fix intermittent test failure; add test for expiring messages after reservation removal 2023-02-12 12:08:56 -05:00
binwiederhier
1f010acb30 Tests for manager.go 2023-02-12 08:29:44 -05:00
binwiederhier
8bf64d8723 A few manager tests 2023-02-11 22:14:09 -05:00
binwiederhier
73b0161ff7 Remove self-review todo 2023-02-11 20:45:04 -05:00
binwiederhier
4cbf1f5371 Derp 2023-02-11 20:38:13 -05:00
binwiederhier
e5a33523d9 Why is this so hard 2023-02-11 14:32:50 -05:00
binwiederhier
224c54b1a2 Fix UI bug with publish dialog 2023-02-11 14:13:10 -05:00
Rycoh
020f561ad4 Translated using Weblate (Romanian)
Currently translated at 4.7% (9 of 189 strings)

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

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

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

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

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

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

1
.github/FUNDING.yml vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,5 @@ MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
COPY ntfy /usr/bin
HEALTHCHECK --interval=60s --timeout=10s CMD wget -q --tries=1 http://localhost/v1/health -O - | grep -Eo '"healthy"\s*:\s*true' || exit 1
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

View File

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

View File

@@ -13,11 +13,9 @@
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
**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.
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, **without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do so since ntfy is open source.
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)).
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android) available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
<p>
<img src="web/public/static/img/screenshot-curl.png" height="180">
@@ -61,9 +59,9 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
</a>
## Sponsors
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. A big fat **Thank You** to the folks already sponsoring ntfy:
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
@@ -110,11 +108,24 @@ appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
<a href="https://github.com/biopsin"><img src="https://github.com/biopsin.png" width="40px" /></a>
<a href="https://github.com/thebino"><img src="https://github.com/thebino.png" width="40px" /></a>
<a href="https://github.com/sky4055"><img src="https://github.com/sky4055.png" width="40px" /></a>
<a href="https://github.com/julianlam"><img src="https://github.com/julianlam.png" width="40px" /></a>
<a href="https://github.com/andreapx"><img src="https://github.com/andreapx.png" width="40px" /></a>
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
<a href="https://github.com/oakd"><img src="https://github.com/oakd.png" width="40px" /></a>
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
<a href="https://github.com/0xAF"><img src="https://github.com/0xAF.png" width="40px" /></a>
<a href="https://github.com/soonoo"><img src="https://github.com/soonoo.png" width="40px" /></a>
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
and [DigitalOcean](https://www.digitalocean.com/) for supporting the project:
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
<a href="https://www.digitalocean.com/"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
## Code of Conduct
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ func init() {
}
var flagsPublish = append(
flagsDefault,
append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
&cli.StringFlag{Name: "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"},
@@ -35,11 +35,11 @@ var flagsPublish = append(
&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.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
&cli.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"},
)
@@ -99,10 +99,18 @@ func execPublish(c *cli.Context) error {
file := c.String("file")
email := c.String("email")
user := c.String("user")
token := c.String("token")
noCache := c.Bool("no-cache")
noFirebase := c.Bool("no-firebase")
quiet := c.Bool("quiet")
pid := c.Int("wait-pid")
// Checks
if user != "" && token != "" {
return errors.New("cannot set both --user and --token")
}
// Do the things
topic, message, command, err := parseTopicMessageCommand(c)
if err != nil {
return err
@@ -144,6 +152,9 @@ func execPublish(c *cli.Context) error {
if noFirebase {
options = append(options, client.WithNoFirebase())
}
if token != "" {
options = append(options, client.WithBearerAuth(token))
}
if user != "" {
var pass string
parts := strings.SplitN(user, ":", 2)

View File

@@ -8,20 +8,27 @@ import (
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
testMessage := util.RandomString(10)
app, _, _, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
time.Sleep(3 * time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
app2, _, stdout, _ := newTestApp()
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
require.Contains(t, stdout.String(), testMessage)
_, err := util.Retry(func() (*int, error) {
app2, _, stdout, _ := newTestApp()
if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil {
return nil, err
}
if !strings.Contains(stdout.String(), testMessage) {
return nil, fmt.Errorf("test message %s not found in topic", testMessage)
}
return util.Int(1), nil
}, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
require.Nil(t, err)
}
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
@@ -127,7 +134,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
// Test: Successful command with NTFY_TOPIC
app, _, stdout, _ = newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"}))
m = toMessage(t, stdout.String())
require.Equal(t, "mytopic", m.Topic)
@@ -136,7 +143,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
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)}))
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
m = toMessage(t, stdout.String())
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
}

View File

@@ -34,7 +34,7 @@ const (
)
var flagsServe = append(
flagsDefault,
append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
@@ -58,6 +58,7 @@ var flagsServe = append(
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
@@ -77,11 +78,13 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
)
var cmdServe = &cli.Command{
@@ -131,6 +134,7 @@ func execServe(c *cli.Context) error {
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-interval")
disallowedTopics := c.StringSlice("disallowed-topics")
webRoot := c.String("web-root")
enableSignup := c.Bool("enable-signup")
enableLogin := c.Bool("enable-login")
@@ -150,11 +154,13 @@ func execServe(c *cli.Context) error {
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
behindProxy := c.Bool("behind-proxy")
stripeSecretKey := c.String("stripe-secret-key")
stripeWebhookKey := c.String("stripe-webhook-key")
billingContact := c.String("billing-contact")
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
@@ -246,11 +252,16 @@ func execServe(c *cli.Context) error {
// Stripe things
if stripeSecretKey != "" {
stripe.EnableTelemetry = false // Whoa!
stripe.Key = stripeSecretKey
}
// Add default forbidden topics
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
// Run server
conf := server.NewConfig()
conf.File = config
conf.BaseURL = baseURL
conf.ListenHTTP = listenHTTP
conf.ListenHTTPS = listenHTTPS
@@ -273,6 +284,7 @@ func execServe(c *cli.Context) error {
conf.AttachmentExpiryDuration = attachmentExpiryDuration
conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval
conf.DisallowedTopics = disallowedTopics
conf.WebRootIsApp = webRootIsApp
conf.UpstreamBaseURL = upstreamBaseURL
conf.SMTPSenderAddr = smtpSenderAddr
@@ -285,15 +297,17 @@ func execServe(c *cli.Context) error {
conf.TotalTopicLimit = totalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.BehindProxy = behindProxy
conf.StripeSecretKey = stripeSecretKey
conf.StripeWebhookKey = stripeWebhookKey
conf.BillingContact = billingContact
conf.EnableWeb = enableWeb
conf.EnableSignup = enableSignup
conf.EnableLogin = enableLogin
@@ -306,9 +320,9 @@ func execServe(c *cli.Context) error {
// Run server
s, err := server.New(conf)
if err != nil {
log.Fatal(err)
log.Fatal(err.Error())
} else if err := s.Run(); err != nil {
log.Fatal(err)
log.Fatal(err.Error())
}
log.Info("Exiting.")
return nil
@@ -335,7 +349,9 @@ func sigHandlerConfigReload(config string) {
log.Warn("Hot reload failed: %s", err.Error())
continue
}
reloadLogLevel(inputSource)
if err := reloadLogLevel(inputSource); err != nil {
log.Warn("Reloading log level failed: %s", err.Error())
}
}
}
@@ -364,13 +380,24 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
return
}
func reloadLogLevel(inputSource altsrc.InputSourceContext) {
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
newLevelStr, err := inputSource.String("log-level")
if err != nil {
log.Warn("Cannot load log level: %s", err.Error())
return
return fmt.Errorf("cannot load log level: %s", err.Error())
}
newLevel := log.ToLevel(newLevelStr)
log.SetLevel(newLevel)
log.Info("Log level is %s", newLevel.String())
overrides, err := inputSource.StringSlice("log-level-overrides")
if err != nil {
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
}
log.ResetLevelOverrides()
if err := applyLogLevelOverrides(overrides); err != nil {
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
}
log.SetLevel(log.ToLevel(newLevelStr))
if len(overrides) > 0 {
log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
} else {
log.Info("Log level is %v", strings.ToUpper(newLevelStr))
}
return nil
}

View File

@@ -26,7 +26,7 @@ const (
)
var flagsSubscribe = append(
flagsDefault,
append([]cli.Flag{}, flagsDefault...),
&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"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},

366
cmd/tier.go Normal file
View File

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

67
cmd/tier_test.go Normal file
View File

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

210
cmd/token.go Normal file
View File

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

50
cmd/token_test.go Normal file
View File

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

View File

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

View File

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

50
docs/_overrides/main.html Normal file
View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block announce %}
<style>
div[data-md-component="announce"] {
z-index: 10;
}
div[data-md-component="announce"] a {
color: white;
}
div[data-md-component="announce"] a:hover, div[data-md-component="announce"] a:focus {
transition: ease-in 150ms;
color: #ccc;
}
div[data-md-component="announce"] .md-banner__button {
color: #ccc;
}
div[data-md-component="announce"] .md-banner.hidden {
display: none;
}
div[data-md-component="announce"] .twemoji {
margin-top: 2px;
}
</style>
<button id="announce-bar-close" class="md-banner__button md-icon" aria-label="Don't show this again">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"></path>
</svg>
</button>
If you like ntfy, please consider sponsoring it via <a target="_blank" href="https://github.com/sponsors/binwiederhier"><strong>GitHub Sponsors</strong></a>
or <a target="_blank" href="https://en.liberapay.com/ntfy/"><strong>Liberapay</strong></a>
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="0 0 36 36" class="twemoji md-footer-custom-text">
<path fill="#DD2E44" d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/>
</svg>
<script>
announceBarKey = 'announce-bar-closed-sponsor';
document.getElementById('announce-bar-close').addEventListener('click', (e) => {
localStorage.setItem(announceBarKey, 'true');
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
});
if (localStorage.getItem(announceBarKey) === 'true') {
document.querySelector('div[data-md-component="announce"] .md-banner').style.display = 'none';
}
</script>
{% endblock %}

View File

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

View File

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

View File

@@ -413,7 +413,8 @@ alerting:
## Jellyseerr/Overseerr webhook
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
JSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click.
And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic.
``` json
{
@@ -571,4 +572,27 @@ Example `template.html`:
Add notification on Rundeck (attachment type must be: `Attached as file to email`):
![Rundeck](static/img/rundeck.png)
## Traccar
This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](https://github.com/traccar/traccar)) instances, as you need to be able to set `sms.http.*` keys, which is not possible through the UI attributes
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
```xml
<entry key='sms.http.url'>https://ntfy.sh</entry>
<entry key='sms.http.template'>
{
"topic": "{phone}",
"message": "{message}"
}
</entry>
```
If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, you'll also have to provide an authorization header, for example in form of a privileged token
```xml
<entry key='sms.http.authorization'>Bearer tk_JhbsnoMrgy2FcfHeofv97Pi5uXaZZ</entry>
```
or by simply providing traccar with a valid username/password combination.
```xml
<entry key='sms.http.user'>phil</entry>
<entry key='sms.http.password'>mypass</entry>
```

View File

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

View File

@@ -26,37 +26,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_x86_64.tar.gz
tar zxvf ntfy_2.1.1_linux_x86_64.tar.gz
sudo cp -a ntfy_2.1.1_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.tar.gz
tar zxvf ntfy_2.1.1_linux_armv6.tar.gz
sudo cp -a ntfy_2.1.1_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.tar.gz
tar zxvf ntfy_2.1.1_linux_armv7.tar.gz
sudo cp -a ntfy_2.1.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.tar.gz
tar zxvf ntfy_2.1.1_linux_arm64.tar.gz
sudo cp -a ntfy_2.1.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -106,7 +106,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -114,7 +114,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -122,7 +122,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -130,7 +130,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,28 +140,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_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.30.1/ntfy_1.30.1_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_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 -L https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz
tar zxvf ntfy_1.30.1_macOS_all.tar.gz
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_macOS_all.tar.gz > ntfy_2.1.1_macOS_all.tar.gz
tar zxvf ntfy_2.1.1_macOS_all.tar.gz
sudo cp -a ntfy_2.1.1_macOS_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_1.30.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.1.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -212,7 +212,7 @@ ntfy --help
## 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.30.1/ntfy_1.30.1_windows_x86_64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_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).
@@ -266,7 +266,7 @@ docker run \
serve
```
Using docker-compose with non-root user:
Using docker-compose with non-root user and healthchecks enabled:
```yaml
version: "2.1"
@@ -284,10 +284,16 @@ services:
- /etc/ntfy:/etc/ntfy
ports:
- 80:80
healthcheck: # optional: remember to adapt the host:port to your environment
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
```
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid.
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
```

View File

@@ -32,8 +32,11 @@ and uptime of third party servers, so use of each server is **at your own discre
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
- [Scrt.link](https://scrt.link/) - Share a secret
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
@@ -71,6 +74,7 @@ and uptime of third party servers, so use of each server is **at your own discre
## Projects + scripts
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
- [ntfy-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)
@@ -107,9 +111,19 @@ and uptime of third party servers, so use of each server is **at your own discre
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog)
## Blog + forum posts
- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023
- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023
- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023
- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023
- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023
- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
@@ -127,6 +141,7 @@ and uptime of third party servers, so use of each server is **at your own discre
- [Ntfy.sh Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022
- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022
- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022
- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/2022
- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022
- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022
- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022

View File

@@ -1292,7 +1292,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
```
The required/optional fields for each action depend on the type of the action itself. Please refer to
[`view` action](#open-websiteapp), [`broadcasst` action](#send-android-broadcast), and [`http` action](#send-http-request)
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request)
for details.
### Open website/app
@@ -2582,6 +2582,11 @@ format is:
ntfy-$topic@ntfy.sh
```
If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, e-mail publishing won't work without providing an authorized access token. That will change the format of the e-mail's recipient address to
```
ntfy-$topic+$token@ntfy.sh
```
As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority,
delay and other features are not supported (yet). Here's an example that will publish a message with the
title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)):
@@ -2591,23 +2596,22 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
<figcaption>Publishing a message via e-mail</figcaption>
</figure>
## Advanced features
### Authentication
## Authentication
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
To publish/subscribe to protected topics, you can:
* Use [basic auth](#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
* or use the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
* Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
* Use [access tokens](#bearer-auth) via Bearer/Basic auth, e.g. `Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`
* or use either with the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
!!! warning
Base64 only encodes username and password. It **is not encrypting it**. For your self-hosted server,
**be sure to use HTTPS to avoid eavesdropping** and exposing your password.
When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your
self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password.
#### Basic auth
Here's an example using [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication), with a user `testuser`
and password `fakepassword`:
### Username & password
The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication).
Here's an example with a user `testuser` and password `fakepassword`:
=== "Command line (curl)"
```
@@ -2701,7 +2705,172 @@ The following command will generate the appropriate value for you on *nix system
echo "Basic $(echo -n 'testuser:fakepassword' | base64)"
```
#### Query param
### Access tokens
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
want to use a dedicated token to publish from your backup host, and one from your home automation system.
You can create access tokens using the `ntfy token` command, or in the web app in the "Account" section (when logged in).
See [access tokens](config.md#access-tokens) for details.
Once an access token is created, you can use it to authenticate against the ntfy server, e.g. when you publish or
subscribe to topics. Here's an example using [Bearer auth](https://swagger.io/docs/specification/authentication/bearer-authentication/),
with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`:
=== "Command line (curl)"
```
curl \
-H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
'content' => 'Look ma, with auth'
]
]));
```
Alternatively, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) to send the
access token. When sending an empty username, the basic auth password is treated by the ntfy server as an
access token. This is primarily useful to make `curl` calls easier, e.g. `curl -u:tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ...`:
=== "Command line (curl)"
```
curl \
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy'
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{Authorization="Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"}
$message = "Look ma, with auth"
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy',
'content' => 'Look ma, with auth'
]
]));
```
### Query param
Here's an example using the `auth` query parameter:
=== "Command line (curl)"
@@ -2766,7 +2935,7 @@ Here's an example using the `auth` query parameter:
]));
```
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see anove) using
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using
**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully
explains it better:
@@ -2786,6 +2955,8 @@ The following command will generate the appropriate value for you on *nix system
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
```
## Advanced features
### Message caching
!!! info
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
@@ -2984,25 +3155,26 @@ that you can use to try out what [authentication and access control](#authentica
|------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------|
| [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such |
| [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage |
| [mytopic-rw](https://ntfy.sh/mytopic-rw) | `testuser` (password: `testuser`) | Read-write for `testuser`, no access for anyone else | Test topic |
| [mytopic-ro](https://ntfy.sh/mytopic-ro) | `testuser` (password: `testuser`) | Read-only for `testuser`, no access for anyone else | Test topic |
| [mytopic-wo](https://ntfy.sh/mytopic-wo) | `testuser` (password: `testuser`) | Write-only for `testuser`, no access for anyone else | Test topic |
## Limitations
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
but just in case, let's list them all:
| Limit | Description |
|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. |
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. |
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
| Limit | Description |
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 1,000. |
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 10. |
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 5 MB, and the per-visitor total is 50 MB. |
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
## List of all parameters
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,

View File

@@ -2,7 +2,151 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
## ntfy server v1.31.0 (UNRELEASED)
## ntfy server v2.1.1
Released March 1, 2023
This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,
**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉.
You are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...),
as well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the
ntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use
promo code `MYTOPIC` for a **50% discount**, limited time only).
And as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There
are no closed-source features. So if you'd like to run your own server, you can!
**Bug fixes + maintenance:**
* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting)
* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha))
* Upgrade dialog: Disable submit button for free tier (no ticket)
* Allow multiple `log-level-overrides` on the same field (no ticket)
* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket)
* Added `billing-contact` config option (no ticket)
## ntfy server v2.1.0
Released February 25, 2023
This release changes the way UnifiedPush (UP) topics are rate limited from publisher-based rate limiting to subscriber-based
rate limiting. This allows UP application servers to send higher volumes, since the subscribers carry the rate limits.
However, it also means that UP clients have to subscribe to a topic first before they are allowed to publish. If they do
no, clients will receive an HTTP 507 response from the server.
We also fixed another issue with UnifiedPush: Some Mastodon servers were sending unsupported `Authorization` headers,
which ntfy rejected with an HTTP 401. We now ignore unsupported header values.
As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing
cycles (not live yet).
As part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate
limits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits
a bit more. For 90% of users, you should not feel the difference.
**Features:**
* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm))
* Support for publishing to protected topics via email with access tokens ([#612](https://github.com/binwiederhier/ntfy/pull/621), thanks to [@tamcore](https://github.com/tamcore))
* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts))
* Payments: Add support for annual billing intervals (no ticket)
**Bug fixes + maintenance:**
* Web: Do not disable "Reserve topic" checkbox for admins (no ticket, thanks to @xenrox for reporting)
* UnifiedPush: Treat non-Basic/Bearer `Authorization` header like header was not sent ([#629](https://github.com/binwiederhier/ntfy/issues/629), thanks to [@Boebbele](https://github.com/Boebbele) and [@S1m](https://github.com/S1m) for reporting)
**Documentation:**
* Added example for [Traccar](https://ntfy.sh/docs/examples/#traccar) ([#631](https://github.com/binwiederhier/ntfy/pull/631), thanks to [tamcore](https://github.com/tamcore))
**Additional languages:**
* Arabic (thanks to [@ButterflyOfFire](https://hosted.weblate.org/user/ButterflyOfFire/))
## ntfy server v2.0.1
Released February 17, 2023
This is a quick bugfix release to address a panic that happens when `attachment-cache-dir` is not set.
**Bug fixes + maintenance:**
* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl))
* Ensure that calls to standard logger `log.Println` also output JSON (no ticket)
## ntfy server v2.0.0
Released February 16, 2023
This is the biggest ntfy server release I've ever done 🥳 . Lots of new and exciting features.
**Brand-new features:**
* **User signup/login & account sync**: If enabled, users can now register to create a user account, and then login to
the web app. Once logged in, topic subscriptions and user settings are stored server-side in the user account (as
opposed to only in the browser storage). So far, this is implemented only in the web app only. Once it's in the Android/iOS
app, you can easily keep your account in sync. Relevant [config options](config.md#config-options) are `enable-signup` and
`enable-login`.
<div id="account-screenshots" class="screenshots">
<a href="../../static/img/web-signup.png"><img src="../../static/img/web-signup.png"/></a>
<a href="../../static/img/web-account.png"><img src="../../static/img/web-account.png"/></a>
</div>
* **Topic reservations** 🎉: If enabled, users can now **reserve topics and restrict access to other users**.
Once this is fully rolled out, you may reserve `ntfy.sh/philbackups` and define access so that only you can publish/subscribe
to the topic. Reservations let you claim ownership of a topic, and you can define access permissions for others as
`deny-all` (only you have full access), `read-only` (you can publish/subscribe, others can subscribe), `write-only` (you
can publish/subscribe, others can publish), `read-write` (everyone can publish/subscribe, but you remain the owner).
Topic reservations can be [configured](config.md#config-options) in the web app if `enable-reservations` is enabled, and
only if the user has a [tier](config.md#tiers) that supports reservations.
<div id="reserve-screenshots" class="screenshots">
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
</div>
* **Access tokens:** It is now possible to create user access tokens for a user account. Access tokens are useful
to avoid having to paste your password to various applications or scripts. For instance, you may want to use a
dedicated token to publish from your backup host, and one from your home automation system. Tokens can be configured
in the web app, or via the `ntfy token` command. See [creating tokens](config.md#access-tokens),
and [publishing using tokens](publish.md#access-tokens).
<div id="token-screenshots" class="screenshots">
<a href="../../static/img/web-token-create.png"><img src="../../static/img/web-token-create.png"/></a>
<a href="../../static/img/web-token-list.png"><img src="../../static/img/web-token-list.png"/></a>
</div>
* **Structured logging:** I've redone a lot of the logging to make it more structured, and to make it easier to debug and
troubleshoot. Logs can now be written to a file, and as JSON (if configured). Each log event carries context fields
that you can filter and search on using tools like `jq`. On top of that, you can override the log level if certain fields
match. For instance, you can say `user_name=phil -> debug` to log everything related to a certain user with debug level.
See [logging & debugging](config.md#logging-debugging).
* **Tiers:** You can now define and associate usage tiers to users. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. You could, for instance, have
a tier `Standard` that allows 500 messages/day, 15 MB attachments and 5 allowed topic reservations, and another
tier `Friends & Family` with much higher limits. For ntfy.sh, I'll mostly use these tiers to facilitate paid plans (see below).
Tiers can be configured via the `ntfy tier ...` command. See [tiers](config.md#tiers).
* **Paid tiers:** Starting very soon, I will be offering paid tiers for ntfy.sh on top of the free service. You'll be
able to subscribe to tiers with higher rate limits (more daily messages, bigger attachments) and topic reservations.
Paid tiers are facilitated by integrating [Stripe](https://stripe.com) as a payment provider. See [payments](config.md#payments)
for details.
**ntfy is forever open source!**
Yes, I will be offering some paid plans. But you don't need to panic! I won't be taking any features away, and everything
will remain forever open source, so you can self-host if you like. Similar to the donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
and [Liberapay](https://en.liberapay.com/ntfy/), paid plans will help pay for the service and keep me motivated to keep
going. It'll only make ntfy better.
**Other tickets:**
* User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522))
* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting)
**Special thanks:**
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, @xenrox, @cmeis, @wunter8 and the others who I forgot.
## ntfy server v1.31.0
Released February 14, 2023
This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting
things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal
of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a
breaking-change upgrade, which required some work to get working again.
**Features:**
@@ -13,12 +157,19 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))
**Documentation:**
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))
* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))
**Additional languages:**
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
## ntfy server v1.30.1
Released December 23, 2022 🎅

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -319,7 +319,7 @@ format of the message. It's very straight forward:
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
| `expires` | ✔️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted |
| `expires` | (✔) | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |

View File

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

38
go.mod
View File

@@ -4,22 +4,22 @@ go 1.18
require (
cloud.google.com/go/firestore v1.9.0 // indirect
cloud.google.com/go/storage v1.28.1 // indirect
cloud.google.com/go/storage v1.29.0 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/emersion/go-smtp v0.16.0
github.com/gabriel-vasile/mimetype v1.4.1
github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.16
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
github.com/stretchr/testify v1.8.1
github.com/urfave/cli/v2 v2.23.7
golang.org/x/crypto v0.4.0
golang.org/x/oauth2 v0.3.0 // indirect
github.com/urfave/cli/v2 v2.24.4
golang.org/x/crypto v0.6.0
golang.org/x/oauth2 v0.5.0 // indirect
golang.org/x/sync v0.1.0
golang.org/x/term v0.3.0
golang.org/x/term v0.5.0
golang.org/x/time v0.3.0
google.golang.org/api v0.105.0
google.golang.org/api v0.111.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -27,39 +27,39 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.10.0
github.com/stripe/stripe-go/v74 v74.5.0
github.com/stripe/stripe-go/v74 v74.9.0
)
require (
cloud.google.com/go v0.107.0 // indirect
cloud.google.com/go/compute v1.14.0 // indirect
cloud.google.com/go v0.110.0 // indirect
cloud.google.com/go/compute v1.18.0 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v0.9.0 // indirect
cloud.google.com/go/longrunning v0.3.0 // indirect
cloud.google.com/go/iam v0.12.0 // indirect
cloud.google.com/go/longrunning v0.4.1 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // 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.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.4.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // 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.2 // indirect
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect
google.golang.org/grpc v1.51.0 // indirect
google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514 // indirect
google.golang.org/grpc v1.53.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

78
go.sum
View File

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

240
log/event.go Normal file
View File

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

View File

@@ -1,78 +1,102 @@
package log
import (
"io"
"log"
"os"
"strings"
"sync"
"time"
)
// Level is a well-known log level, as defined below
type Level int
// Well known log levels
const (
TraceLevel Level = iota
DebugLevel
InfoLevel
WarnLevel
ErrorLevel
// Defaults for package level variables
var (
DefaultLevel = InfoLevel
DefaultFormat = TextFormat
DefaultOutput = &peekLogWriter{os.Stderr}
)
func (l Level) String() string {
switch l {
case TraceLevel:
return "TRACE"
case DebugLevel:
return "DEBUG"
case InfoLevel:
return "INFO"
case WarnLevel:
return "WARN"
case ErrorLevel:
return "ERROR"
}
return "unknown"
}
var (
level = InfoLevel
mu = &sync.Mutex{}
level = DefaultLevel
format = DefaultFormat
overrides = make(map[string][]*levelOverride)
output io.Writer = DefaultOutput
filename = ""
mu = &sync.RWMutex{}
)
// Trace prints the given message, if the current log level is TRACE
func Trace(message string, v ...any) {
logIf(TraceLevel, message, v...)
// init sets the default log output (including log.SetOutput)
//
// This has to be explicitly called, because DefaultOutput is a peekLogWriter,
// which wraps os.Stderr.
func init() {
SetOutput(DefaultOutput)
}
// Debug prints the given message, if the current log level is DEBUG or lower
func Debug(message string, v ...any) {
logIf(DebugLevel, message, v...)
}
// Info prints the given message, if the current log level is INFO or lower
func Info(message string, v ...any) {
logIf(InfoLevel, message, v...)
}
// Warn prints the given message, if the current log level is WARN or lower
func Warn(message string, v ...any) {
logIf(WarnLevel, message, v...)
// Fatal prints the given message, and exits the program
func Fatal(message string, v ...any) {
newEvent().Fatal(message, v...)
}
// Error prints the given message, if the current log level is ERROR or lower
func Error(message string, v ...any) {
logIf(ErrorLevel, message, v...)
newEvent().Error(message, v...)
}
// Fatal prints the given message, and exits the program
func Fatal(v ...any) {
log.Fatalln(v...)
// Warn prints the given message, if the current log level is WARN or lower
func Warn(message string, v ...any) {
newEvent().Warn(message, v...)
}
// Info prints the given message, if the current log level is INFO or lower
func Info(message string, v ...any) {
newEvent().Info(message, v...)
}
// Debug prints the given message, if the current log level is DEBUG or lower
func Debug(message string, v ...any) {
newEvent().Debug(message, v...)
}
// Trace prints the given message, if the current log level is TRACE
func Trace(message string, v ...any) {
newEvent().Trace(message, v...)
}
// With creates a new log event and adds the fields of the given Contexter structs
func With(contexts ...Contexter) *Event {
return newEvent().With(contexts...)
}
// Field creates a new log event and adds a custom field and value to it
func Field(key string, value any) *Event {
return newEvent().Field(key, value)
}
// Fields creates a new log event and adds a map of fields to it
func Fields(fields Context) *Event {
return newEvent().Fields(fields)
}
// Tag creates a new log event and adds a "tag" field to it
func Tag(tag string) *Event {
return newEvent().Tag(tag)
}
// Time creates a new log event and sets the time field
func Time(time time.Time) *Event {
return newEvent().Time(time)
}
// Timing runs f and records the time if took to execute it in "time_taken_ms"
func Timing(f func()) *Event {
return newEvent().Timing(f)
}
// CurrentLevel returns the current log level
func CurrentLevel() Level {
mu.Lock()
defer mu.Unlock()
mu.RLock()
defer mu.RUnlock()
return level
}
@@ -83,30 +107,72 @@ func SetLevel(newLevel Level) {
level = newLevel
}
// SetLevelOverride adds a log override for the given field
func SetLevelOverride(field string, value string, level Level) {
mu.Lock()
defer mu.Unlock()
if _, ok := overrides[field]; !ok {
overrides[field] = make([]*levelOverride, 0)
}
overrides[field] = append(overrides[field], &levelOverride{value: value, level: level})
}
// ResetLevelOverrides removes all log level overrides
func ResetLevelOverrides() {
mu.Lock()
defer mu.Unlock()
overrides = make(map[string][]*levelOverride)
}
// CurrentFormat returns the current log format
func CurrentFormat() Format {
mu.RLock()
defer mu.RUnlock()
return format
}
// SetFormat sets a new log format
func SetFormat(newFormat Format) {
mu.Lock()
defer mu.Unlock()
format = newFormat
if newFormat == JSONFormat {
DisableDates()
}
}
// SetOutput sets the log output writer
func SetOutput(w io.Writer) {
mu.Lock()
defer mu.Unlock()
output = &peekLogWriter{w}
if f, ok := w.(*os.File); ok {
filename = f.Name()
} else {
filename = ""
}
log.SetOutput(output)
}
// File returns the log file, if any, or an empty string otherwise
func File() string {
mu.RLock()
defer mu.RUnlock()
return filename
}
// IsFile returns true if the output is a non-default file
func IsFile() bool {
mu.RLock()
defer mu.RUnlock()
return filename != ""
}
// DisableDates disables the date/time prefix
func DisableDates() {
log.SetFlags(0)
}
// ToLevel converts a string to a Level. It returns InfoLevel if the string
// does not match any known log levels.
func ToLevel(s string) Level {
switch strings.ToUpper(s) {
case "TRACE":
return TraceLevel
case "DEBUG":
return DebugLevel
case "INFO":
return InfoLevel
case "WARN", "WARNING":
return WarnLevel
case "ERROR":
return ErrorLevel
default:
return InfoLevel
}
}
// Loggable returns true if the given log level is lower or equal to the current log level
func Loggable(l Level) bool {
return CurrentLevel() <= l
@@ -122,8 +188,19 @@ func IsDebug() bool {
return Loggable(DebugLevel)
}
func logIf(l Level, message string, v ...any) {
if CurrentLevel() <= l {
log.Printf(l.String()+" "+message, v...)
}
// peekLogWriter is an io.Writer which will peek at the rendered log event,
// and ensure that the rendered output is valid JSON. This is a hack!
type peekLogWriter struct {
w io.Writer
}
func (w *peekLogWriter) Write(p []byte) (n int, err error) {
if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {
return w.w.Write(p)
}
m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p)))
if m == "" {
return 0, nil
}
return w.w.Write([]byte(m + "\n"))
}

279
log/log_test.go Normal file
View File

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

115
log/types.go Normal file
View File

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

View File

@@ -10,6 +10,7 @@ edit_uri: blob/main/docs/
theme:
name: material
language: en
custom_dir: docs/_overrides
logo: static/img/ntfy.png
favicon: static/img/favicon.png
include_search_page: false
@@ -76,7 +77,7 @@ nav:
- "Sending messages": publish.md
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web UI": subscribe/web.md
- "From the Web app": subscribe/web.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":

View File

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

View File

@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"net/http"
)
@@ -12,6 +13,7 @@ type errHTTP struct {
HTTPCode int `json:"http"`
Message string `json:"error"`
Link string `json:"link,omitempty"`
context log.Context
}
func (e errHTTP) Error() string {
@@ -23,61 +25,107 @@ func (e errHTTP) JSON() string {
return string(b)
}
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
return &errHTTP{
Code: err.Code,
HTTPCode: err.HTTPCode,
Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)),
Link: err.Link,
func (e errHTTP) Context() log.Context {
context := log.Context{
"error": e.Message,
"error_code": e.Code,
"http_status": e.HTTPCode,
}
for k, v := range e.context {
context[k] = v
}
return context
}
func (e errHTTP) Wrap(message string, args ...any) *errHTTP {
clone := e.clone()
clone.Message = fmt.Sprintf("%s; %s", clone.Message, fmt.Sprintf(message, args...))
return &clone
}
func (e errHTTP) With(contexters ...log.Contexter) *errHTTP {
c := e.clone()
if c.context == nil {
c.context = make(log.Context)
}
for _, contexter := range contexters {
c.context.Merge(contexter.Context())
}
return &c
}
func (e errHTTP) Fields(context log.Context) *errHTTP {
c := e.clone()
if c.context == nil {
c.context = make(log.Context)
}
c.context.Merge(context)
return &c
}
func (e errHTTP) clone() errHTTP {
context := make(log.Context)
for k, v := range e.context {
context[k] = v
}
return errHTTP{
Code: e.Code,
HTTPCode: e.HTTPCode,
Message: e.Message,
Link: e.Link,
context: context,
}
}
var (
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is disallowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
errHTTPBadRequestMessageJSONInvalid = &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"}
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"}
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
errHTTPBadRequestMakesNoSenseForAdmin = &errHTTP{40026, http.StatusBadRequest, "invalid request: this makes no sense for admins", ""}
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
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"}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
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"}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: too many messages", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil}
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil}
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil}
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil}
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil}
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil}
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil}
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil}
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
errHTTPInsufficientStorage = &errHTTP{50701, http.StatusInsufficientStorage, "internal server error: cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
)

View File

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

123
server/log.go Normal file
View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

166
server/server_manager.go Normal file
View File

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

View File

@@ -0,0 +1,28 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) {
// Tests that the manager runs without attachment-cache-dir set, see #617
c := newTestConfig(t)
c.AttachmentCacheDir = ""
s := newTestServer(t, c)
// Publish a message
rr := request(t, s, "POST", "/mytopic", "hi", nil)
require.Equal(t, 200, rr.Code)
m := toMessage(t, rr.Body.String())
// Expire message
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
// Does not panic
s.pruneMessages()
// Actually deleted
_, err := s.messageCache.Message(m.ID)
require.Equal(t, errMessageNotFound, err)
}

View File

@@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"net/http"
@@ -72,25 +71,19 @@ type matrixResponse struct {
Rejected []string `json:"rejected"`
}
// errMatrix represents an error when handing Matrix gateway messages
type errMatrix struct {
pushKey string
err error
// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages
//
// If the push key is set, the app server will remove it and will never send messages using the same
// push key again, until the user repairs it.
type errMatrixPushkeyRejected struct {
rejectedPushKey string
configuredBaseURL string
}
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)
func (e errMatrixPushkeyRejected) Error() string {
return fmt.Sprintf("push key must be prefixed with base URL, received push key: %s, configured base URL: %s", e.rejectedPushKey, e.configuredBaseURL)
}
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.
//
@@ -123,17 +116,16 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
}
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)}
return nil, &errMatrixPushkeyRejected{rejectedPushKey: pushKey, configuredBaseURL: baseURL}
}
newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))
if err != nil {
return nil, &errMatrix{pushKey: pushKey, err: err}
return nil, 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
}
@@ -145,12 +137,6 @@ func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
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, "")

View File

@@ -3,7 +3,6 @@ package server
import (
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
@@ -19,7 +18,6 @@ func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {
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))
}
@@ -56,10 +54,10 @@ func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {
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)
matrixErr, ok := err.(*errMatrixPushkeyRejected)
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)
require.Equal(t, "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.Error())
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.rejectedPushKey)
}
func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
@@ -71,9 +69,7 @@ func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
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, nil, netip.MustParseAddr("1.2.3.4"), nil)
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
require.Nil(t, writeMatrixResponse(w, "https://ntfy.example.com/upABCDEFGHI?up=1"))
require.Equal(t, 200, w.Result().StatusCode)
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
}

View File

@@ -2,8 +2,52 @@ package server
import (
"net/http"
"heckel.io/ntfy/util"
)
type contextKey int
const (
contextRateVisitor contextKey = iota + 2586
contextTopic
)
func (s *Server) limitRequests(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
return next(w, r, v)
} else if !v.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests
}
return next(w, r, v)
}
}
// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context
func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
t, err := s.topicFromPath(r.URL.Path)
if err != nil {
return err
}
vrate := v
if rateVisitor := t.RateVisitor(); rateVisitor != nil {
vrate = rateVisitor
}
r = withContext(r, map[contextKey]any{
contextRateVisitor: vrate,
contextTopic: t,
})
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
return next(w, r, v)
} else if !vrate.RequestAllowed() {
return errHTTPTooManyRequestsLimitRequests
}
return next(w, r, v)
}
}
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if !s.config.EnableWeb {
@@ -24,7 +68,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc {
func (s *Server) ensureUser(next handleFunc) handleFunc {
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user == nil {
if v.User() == nil {
return errHTTPUnauthorized
}
return next(w, r, v)
@@ -42,7 +86,7 @@ func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user.Billing.StripeCustomerID == "" {
if v.User().Billing.StripeCustomerID == "" {
return errHTTPBadRequestNotAPaidUser
}
return next(w, r, v)
@@ -51,9 +95,6 @@ func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
func (s *Server) withAccountSync(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if v.user == nil {
return next(w, r, v)
}
err := next(w, r, v)
if err == nil {
s.publishSyncEventAsync(v)

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,10 +2,10 @@ package server
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/log"
"io"
"mime"
"mime/multipart"
@@ -22,9 +22,14 @@ var (
errInvalidAddress = errors.New("invalid address")
errInvalidTopic = errors.New("invalid topic")
errTooManyRecipients = errors.New("too many recipients")
errMultipartNestedTooDeep = errors.New("multipart message nested too deep")
errUnsupportedContentType = errors.New("unsupported content type")
)
const (
maxMultipartDepth = 2
)
// smtpBackend implements SMTP server methods.
type smtpBackend struct {
config *Config
@@ -34,6 +39,9 @@ type smtpBackend struct {
mu sync.Mutex
}
var _ smtp.Backend = (*smtpBackend)(nil)
var _ smtp.Session = (*smtpSession)(nil)
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
return &smtpBackend{
config: conf,
@@ -41,14 +49,9 @@ func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Reques
}
}
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
return &smtpSession{backend: b, state: state}, nil
}
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
return &smtpSession{backend: b, state: state}, nil
func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
logem(conn).Debug("Incoming mail")
return &smtpSession{backend: b, conn: conn}, nil
}
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
@@ -60,24 +63,26 @@ func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
// smtpSession is returned after EHLO.
type smtpSession struct {
backend *smtpBackend
state *smtp.ConnectionState
conn *smtp.Conn
topic string
token string
mu sync.Mutex
}
func (s *smtpSession) AuthPlain(username, password string) error {
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
func (s *smtpSession) AuthPlain(username, _ string) error {
logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
return nil
}
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
logem(s.conn).Field("smtp_mail_from", from).Debug("MAIL FROM: %s", from)
return nil
}
func (s *smtpSession) Rcpt(to string) error {
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to)
return s.withFailCount(func() error {
token := ""
conf := s.backend.config
addressList, err := mail.ParseAddressList(to)
if err != nil {
@@ -89,18 +94,27 @@ func (s *smtpSession) Rcpt(to string) error {
if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
return errInvalidDomain
}
// Remove @ntfy.sh from end of email
to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
if conf.SMTPServerAddrPrefix != "" {
if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
return errInvalidAddress
}
// remove ntfy- from beginning of email
to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
}
// If email contains token, split topic and token
if strings.Contains(to, "+") {
parts := strings.Split(to, "+")
to = parts[0]
token = parts[1]
}
if !topicRegex.MatchString(to) {
return errInvalidTopic
}
s.mu.Lock()
s.topic = to
s.token = token
s.mu.Unlock()
return nil
})
@@ -113,16 +127,17 @@ func (s *smtpSession) Data(r io.Reader) error {
if err != nil {
return err
}
if log.IsTrace() {
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
} else if log.IsDebug() {
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
ev := logem(s.conn)
if ev.IsTrace() {
ev.Field("smtp_data", string(b)).Trace("DATA")
} else if ev.IsDebug() {
ev.Field("smtp_data_len", len(b)).Debug("DATA")
}
msg, err := mail.ReadMessage(bytes.NewReader(b))
if err != nil {
return err
}
body, err := readMailBody(msg)
body, err := readMailBody(msg.Body, msg.Header)
if err != nil {
return err
}
@@ -156,11 +171,10 @@ func (s *smtpSession) Data(r io.Reader) error {
func (s *smtpSession) publishMessage(m *message) error {
// Extract remote address (for rate limiting)
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
if err != nil {
remoteAddr = s.state.RemoteAddr.String()
remoteAddr = s.conn.Conn().RemoteAddr().String()
}
// Call HTTP handler with fake HTTP request
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
@@ -173,6 +187,9 @@ func (s *smtpSession) publishMessage(m *message) error {
if m.Title != "" {
req.Header.Set("Title", m.Title)
}
if s.token != "" {
req.Header.Add("Authorization", "Bearer "+s.token)
}
rr := httptest.NewRecorder()
s.backend.handler(rr, req)
if rr.Code != http.StatusOK {
@@ -198,54 +215,58 @@ func (s *smtpSession) withFailCount(fn func() error) error {
if err != nil {
// Almost all of these errors are parse errors, and user input errors.
// We do not want to spam the log with WARN messages.
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
logem(s.conn).Err(err).Debug("Incoming mail error")
s.backend.failure++
}
return err
}
func readMailBody(msg *mail.Message) (string, error) {
if msg.Header.Get("Content-Type") == "" {
return readPlainTextMailBody(msg)
func readMailBody(body io.Reader, header mail.Header) (string, error) {
if header.Get("Content-Type") == "" {
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
}
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
contentType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
if err != nil {
return "", err
}
if contentType == "text/plain" {
return readPlainTextMailBody(msg)
} else if strings.HasPrefix(contentType, "multipart/") {
return readMultipartMailBody(msg, params)
if strings.ToLower(contentType) == "text/plain" {
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
return readMultipartMailBody(body, params, 0)
}
return "", errUnsupportedContentType
}
func readPlainTextMailBody(msg *mail.Message) (string, error) {
body, err := io.ReadAll(msg.Body)
if err != nil {
return "", err
func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) {
if depth >= maxMultipartDepth {
return "", errMultipartNestedTooDeep
}
return string(body), nil
}
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
mr := multipart.NewReader(msg.Body, params["boundary"])
mr := multipart.NewReader(body, params["boundary"])
for {
part, err := mr.NextPart()
if err != nil { // may be io.EOF
return "", err
}
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if partContentType != "text/plain" {
continue
if strings.ToLower(partContentType) == "text/plain" {
return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding"))
} else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") {
return readMultipartMailBody(part, partParams, depth+1)
}
body, err := io.ReadAll(part)
if err != nil {
return "", err
}
return string(body), nil
// Continue with next part
}
}
func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {
if strings.ToLower(transferEncoding) == "base64" {
reader = base64.NewDecoder(base64.StdEncoding, reader)
}
body, err := io.ReadAll(reader)
if err != nil {
return "", err
}
return string(body), nil
}

View File

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

View File

@@ -10,8 +10,15 @@ import (
// can publish a message
type topic struct {
ID string
subscribers map[int]subscriber
mu sync.Mutex
subscribers map[int]*topicSubscriber
rateVisitor *visitor
mu sync.RWMutex
}
type topicSubscriber struct {
userID string // User ID associated with this subscription, may be empty
subscriber subscriber
cancel func()
}
// subscriber is a function that is called for every new message on a topic
@@ -21,19 +28,47 @@ type subscriber func(v *visitor, msg *message) error
func newTopic(id string) *topic {
return &topic{
ID: id,
subscribers: make(map[int]subscriber),
subscribers: make(map[int]*topicSubscriber),
}
}
// Subscribe subscribes to this topic
func (t *topic) Subscribe(s subscriber) int {
func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
t.mu.Lock()
defer t.mu.Unlock()
subscriberID := rand.Int()
t.subscribers[subscriberID] = s
t.subscribers[subscriberID] = &topicSubscriber{
userID: userID, // May be empty
subscriber: s,
cancel: cancel,
}
return subscriberID
}
func (t *topic) Stale() bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.rateVisitor != nil && !t.rateVisitor.Stale() {
return false
}
return len(t.subscribers) == 0
}
func (t *topic) SetRateVisitor(v *visitor) {
t.mu.Lock()
defer t.mu.Unlock()
t.rateVisitor = v
}
func (t *topic) RateVisitor() *visitor {
t.mu.Lock()
defer t.mu.Unlock()
if t.rateVisitor != nil && t.rateVisitor.Stale() {
t.rateVisitor = nil
}
return t.rateVisitor
}
// Unsubscribe removes the subscription from the list of subscribers
func (t *topic) Unsubscribe(id int) {
t.mu.Lock()
@@ -48,18 +83,18 @@ func (t *topic) Publish(v *visitor, m *message) error {
// subscribers map here. Actually sending out the messages then doesn't have to lock.
subscribers := t.subscribersCopy()
if len(subscribers) > 0 {
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(subscribers))
logvm(v, m).Tag(tagPublish).Debug("Forwarding to %d subscriber(s)", len(subscribers))
for _, s := range subscribers {
// 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))
logvm(v, m).Tag(tagPublish).Err(err).Warn("Error forwarding to subscriber")
}
}(s)
}(s.subscriber)
}
} else {
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding")
}
}()
return nil
@@ -67,18 +102,55 @@ func (t *topic) Publish(v *visitor, m *message) error {
// SubscribersCount returns the number of subscribers to this topic
func (t *topic) SubscribersCount() int {
t.mu.Lock()
defer t.mu.Unlock()
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.subscribers)
}
// subscribersCopy returns a shallow copy of the subscribers map
func (t *topic) subscribersCopy() map[int]subscriber {
// CancelSubscribers calls the cancel function for all subscribers, forcing
func (t *topic) CancelSubscribers(exceptUserID string) {
t.mu.Lock()
defer t.mu.Unlock()
subscribers := make(map[int]subscriber)
for k, v := range t.subscribers {
subscribers[k] = v
for _, s := range t.subscribers {
if s.userID != exceptUserID {
log.
Tag(tagSubscribe).
With(t).
Fields(log.Context{
"user_id": s.userID,
}).
Debug("Canceling subscriber %s", s.userID)
s.cancel()
}
}
}
func (t *topic) Context() log.Context {
t.mu.RLock()
defer t.mu.RUnlock()
fields := map[string]any{
"topic": t.ID,
"topic_subscribers": len(t.subscribers),
}
if t.rateVisitor != nil {
for k, v := range t.rateVisitor.Context() {
fields["topic_rate_"+k] = v
}
}
return fields
}
// subscribersCopy returns a shallow copy of the subscribers map
func (t *topic) subscribersCopy() map[int]*topicSubscriber {
t.mu.Lock()
defer t.mu.Unlock()
subscribers := make(map[int]*topicSubscriber)
for k, sub := range t.subscribers {
subscribers[k] = &topicSubscriber{
userID: sub.userID,
subscriber: sub.subscriber,
cancel: sub.cancel,
}
}
return subscribers
}

30
server/topic_test.go Normal file
View File

@@ -0,0 +1,30 @@
package server
import (
"github.com/stretchr/testify/require"
"sync/atomic"
"testing"
)
func TestTopic_CancelSubscribers(t *testing.T) {
t.Parallel()
subFn := func(v *visitor, msg *message) error {
return nil
}
canceled1 := atomic.Bool{}
cancelFn1 := func() {
canceled1.Store(true)
}
canceled2 := atomic.Bool{}
cancelFn2 := func() {
canceled2.Store(true)
}
to := newTopic("mytopic")
to.Subscribe(subFn, "", cancelFn1)
to.Subscribe(subFn, "u_phil", cancelFn2)
to.CancelSubscribers("u_phil")
require.True(t, canceled1.Load())
require.False(t, canceled2.Load())
}

View File

@@ -1,6 +1,7 @@
package server
import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"net/http"
"net/netip"
@@ -42,6 +43,23 @@ type message struct {
User string `json:"-"` // Username of the uploader, used to associated attachments
}
func (m *message) Context() log.Context {
fields := map[string]any{
"topic": m.Topic,
"message_id": m.ID,
"message_time": m.Time,
"message_event": m.Event,
"message_body_size": len(m.Message),
}
if m.Sender.IsValid() {
fields["message_sender"] = m.Sender.String()
}
if m.User != "" {
fields["message_user"] = m.User
}
return fields
}
type attachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
@@ -227,12 +245,31 @@ type apiAccountCreateRequest struct {
}
type apiAccountPasswordChangeRequest struct {
Password string `json:"password"`
NewPassword string `json:"new_password"`
}
type apiAccountDeleteRequest struct {
Password string `json:"password"`
}
type apiAccountTokenIssueRequest struct {
Label *string `json:"label"`
Expires *int64 `json:"expires"` // Unix timestamp
}
type apiAccountTokenUpdateRequest struct {
Token string `json:"token"`
Label *string `json:"label"`
Expires *int64 `json:"expires"` // Unix timestamp
}
type apiAccountTokenResponse struct {
Token string `json:"token"`
Expires int64 `json:"expires"`
Token string `json:"token"`
Label string `json:"label,omitempty"`
LastAccess int64 `json:"last_access,omitempty"`
LastOrigin string `json:"last_origin,omitempty"`
Expires int64 `json:"expires,omitempty"` // Unix timestamp
}
type apiAccountTier struct {
@@ -241,7 +278,7 @@ type apiAccountTier struct {
}
type apiAccountLimits struct {
Basis string `json:"basis,omitempty"` // "ip", "role" or "tier"
Basis string `json:"basis,omitempty"` // "ip" or "tier"
Messages int64 `json:"messages"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
Emails int64 `json:"emails"`
@@ -249,6 +286,7 @@ type apiAccountLimits struct {
AttachmentTotalSize int64 `json:"attachment_total_size"`
AttachmentFileSize int64 `json:"attachment_file_size"`
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
AttachmentBandwidth int64 `json:"attachment_bandwidth"`
}
type apiAccountStats struct {
@@ -271,22 +309,24 @@ type apiAccountBilling struct {
Customer bool `json:"customer"`
Subscription bool `json:"subscription"`
Status string `json:"status,omitempty"`
Interval string `json:"interval,omitempty"`
PaidUntil int64 `json:"paid_until,omitempty"`
CancelAt int64 `json:"cancel_at,omitempty"`
}
type apiAccountResponse struct {
Username string `json:"username"`
Role string `json:"role,omitempty"`
SyncTopic string `json:"sync_topic,omitempty"`
Language string `json:"language,omitempty"`
Notification *user.NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`
Billing *apiAccountBilling `json:"billing,omitempty"`
Username string `json:"username"`
Role string `json:"role,omitempty"`
SyncTopic string `json:"sync_topic,omitempty"`
Language string `json:"language,omitempty"`
Notification *user.NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`
Billing *apiAccountBilling `json:"billing,omitempty"`
}
type apiAccountReservationRequest struct {
@@ -301,14 +341,20 @@ type apiConfigResponse struct {
EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"`
EnableReservations bool `json:"enable_reservations"`
BillingContact string `json:"billing_contact"`
DisallowedTopics []string `json:"disallowed_topics"`
}
type apiAccountBillingPrices struct {
Month int64 `json:"month"`
Year int64 `json:"year"`
}
type apiAccountBillingTier struct {
Code string `json:"code,omitempty"`
Name string `json:"name,omitempty"`
Price string `json:"price,omitempty"`
Limits *apiAccountLimits `json:"limits"`
Code string `json:"code,omitempty"`
Name string `json:"name,omitempty"`
Prices *apiAccountBillingPrices `json:"prices,omitempty"`
Limits *apiAccountLimits `json:"limits"`
}
type apiAccountBillingSubscriptionCreateResponse struct {
@@ -316,7 +362,8 @@ type apiAccountBillingSubscriptionCreateResponse struct {
}
type apiAccountBillingSubscriptionChangeRequest struct {
Tier string `json:"tier"`
Tier string `json:"tier"`
Interval string `json:"interval"`
}
type apiAccountBillingPortalRedirectResponse struct {
@@ -346,12 +393,16 @@ type apiStripeSubscriptionUpdatedEvent struct {
Items *struct {
Data []*struct {
Price *struct {
ID string `json:"id"`
ID string `json:"id"`
Recurring *struct {
Interval string `json:"interval"`
} `json:"recurring"`
} `json:"price"`
} `json:"data"`
} `json:"items"`
}
type apiStripeSubscriptionDeletedEvent struct {
ID string `json:"id"`
Customer string `json:"customer"`
}

View File

@@ -1,15 +1,13 @@
package server
import (
"context"
"fmt"
"github.com/emersion/go-smtp"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"net/http"
"net/netip"
"strings"
"unicode/utf8"
)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
@@ -20,6 +18,17 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
return value == "1" || value == "yes" || value == "true"
}
func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) {
paramStr := readParam(r, names...)
if paramStr != "" {
params = make([]string, 0)
for _, s := range util.SplitNoEmpty(paramStr, ",") {
params = append(params, strings.TrimSpace(s))
}
}
return params
}
func readParam(r *http.Request, names ...string) string {
value := readHeaderParam(r, names...)
if value != "" {
@@ -48,51 +57,6 @@ func readQueryParam(r *http.Request, names ...string) string {
return ""
}
func logMessagePrefix(v *visitor, m *message) string {
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
}
func logHTTPPrefix(v *visitor, r *http.Request) string {
requestURI := r.RequestURI
if requestURI == "" {
requestURI = r.URL.Path
}
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
}
func logSMTPPrefix(state *smtp.ConnectionState) string {
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
}
func renderHTTPRequest(r *http.Request) string {
peekLimit := 4096
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
for key, values := range r.Header {
for _, value := range values {
lines += fmt.Sprintf("%s: %s\n", key, value)
}
}
lines += "\n"
body, err := util.Peek(r.Body, peekLimit)
if err != nil {
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
} else if utf8.Valid(body.PeekedBytes) {
lines += string(body.PeekedBytes)
if body.LimitReached {
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
}
lines += "\n"
} else {
if body.LimitReached {
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
} else {
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
}
}
r.Body = body // Important: Reset body, so it can be re-read
return strings.TrimSpace(lines)
}
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
remoteAddr := r.RemoteAddr
addrPort, err := netip.ParseAddrPort(remoteAddr)
@@ -103,7 +67,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
if err != nil {
ip = netip.IPv4Unspecified()
if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used
log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err)
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr)
}
}
}
@@ -114,7 +78,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
if err != nil {
log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error())
logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip)
// Fall back to regular remote address if X-Forwarded-For is damaged
} else {
ip = realIP
@@ -123,8 +87,8 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
return ip
}
func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
obj, err := util.UnmarshalJSONWithLimit[T](r, limit)
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
if err == util.ErrUnmarshalJSON {
return nil, errHTTPBadRequestJSONInvalid
} else if err == util.ErrTooLargeJSON {
@@ -134,3 +98,19 @@ func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
}
return obj, nil
}
func withContext(r *http.Request, ctx map[contextKey]any) *http.Request {
c := r.Context()
for k, v := range ctx {
c = context.WithValue(c, k, v)
}
return r.WithContext(c)
}
func fromContext[T any](r *http.Request, key contextKey) *T {
t, ok := r.Context().Value(key).(*T)
if !ok {
panic(fmt.Sprintf("cannot find key %v in request context", key))
}
return t
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

63
user/types_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ var (
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
// of that time from the current time (in UTC).
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
hour, minute, seconds := timeOfDay.Clock()
hour, minute, seconds := timeOfDay.UTC().Clock()
now := base.UTC()
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC)
if next.Before(now) {
@@ -45,15 +45,9 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
return time.Time{}, errUnparsableTime
}
func parseFromDuration(s string, now time.Time) (time.Time, error) {
d, err := parseDuration(s)
if err == nil {
return now.Add(d), nil
}
return time.Time{}, errUnparsableTime
}
func parseDuration(s string) (time.Duration, error) {
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
// translates to 24 hours, e.g. "2d" or "20h".
func ParseDuration(s string) (time.Duration, error) {
d, err := time.ParseDuration(s)
if err == nil {
return d, nil
@@ -80,6 +74,14 @@ func parseDuration(s string) (time.Duration, error) {
return 0, errUnparsableTime
}
func parseFromDuration(s string, now time.Time) (time.Time, error) {
d, err := ParseDuration(s)
if err == nil {
return now.Add(d), nil
}
return time.Time{}, errUnparsableTime
}
func parseUnixTime(s string, now time.Time) (time.Time, error) {
t, err := strconv.Atoi(s)
if err != nil {

View File

@@ -78,3 +78,17 @@ func TestParseFutureTime_UnixTime(t *testing.T) {
require.Nil(t, err)
require.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d)
}
func TestParseDuration(t *testing.T) {
d, err := ParseDuration("2d")
require.Nil(t, err)
require.Equal(t, 48*time.Hour, d)
d, err = ParseDuration("2h")
require.Nil(t, err)
require.Equal(t, 2*time.Hour, d)
d, err = ParseDuration("0")
require.Nil(t, err)
require.Equal(t, time.Duration(0), d)
}

View File

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

View File

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

13837
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,281 @@
{
"action_bar_logo_alt": "شعار ntfy",
"action_bar_settings": "اﻹعدادات",
"action_bar_clear_notifications": "محو كافة الإشعارات",
"action_bar_unsubscribe": "إلغاء الاشتراك",
"message_bar_show_dialog": "إظهار مربع حوار النشر",
"message_bar_publish": "نشر الرسالة",
"nav_topics_title": "المواضيع التي تم الاشتراك فيها",
"nav_button_all_notifications": "كافة الإشعارات",
"nav_button_settings": "اﻹعدادات",
"nav_button_documentation": "الدليل",
"nav_button_publish_message": "نشر الإشعار",
"nav_button_subscribe": "اشترك في الموضوع",
"nav_button_connecting": "جارٍ الاتصال",
"alert_grant_title": "تم تعطيل الإشعارات",
"alert_grant_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.",
"notifications_list": "قائمة الإشعارات",
"notifications_list_item": "إشعار",
"notifications_mark_read": "وضع علامة كمقروء",
"notifications_tags": "الوسوم",
"notifications_priority_x": "الأولوية {{priority}}",
"notifications_new_indicator": "إشعار جديد",
"notifications_attachment_image": "صورة مرفقة",
"notifications_attachment_copy_url_button": "نسخ عنوان URL",
"notifications_attachment_open_title": "انتقل إلى {{url}}",
"notifications_attachment_link_expires": "تنتهي صلاحية الرابط {{date}}",
"notifications_attachment_link_expired": "انتهت صلاحية رابط التنزيل",
"notifications_attachment_file_image": "ملف الصورة",
"notifications_attachment_file_video": "ملف فيديو",
"notifications_attachment_file_audio": "ملف صوتي",
"notifications_attachment_file_app": "ملف تطبيق Android",
"notifications_attachment_file_document": "وثيقة أخرى",
"notifications_click_copy_url_button": "نسخ الرابط",
"notifications_click_open_button": "فتح الرابط",
"notifications_actions_open_url_title": "انتقل إلى {{url}}",
"notifications_actions_not_supported": "هذا الإجراء غير مدعوم في تطبيق الويب",
"action_bar_send_test_notification": "إرسال إشعار للاختبار",
"action_bar_show_menu": "عرض القائمة",
"message_bar_type_message": "اكتب رسالة هنا",
"alert_not_supported_title": "الإشعارات غير مدعومة",
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
"notifications_delete": "حذف",
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
"action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات",
"alert_grant_button": "امنح الآن",
"notifications_attachment_open_button": "فتح المرفق",
"notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة",
"notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة",
"notifications_none_for_topic_title": "لم تتلق بعد أية إشعارات حول هذا الموضوع.",
"notifications_none_for_any_title": "لم تتلق أية إشعارات.",
"notifications_no_subscriptions_title": "يبدو أنك لا تملك أي اشتراكات بعد.",
"notifications_example": "مثال",
"notifications_loading": "تحميل الإشعارات…",
"publish_dialog_title_topic": "أنشُر إلى {{topic}}",
"publish_dialog_title_no_topic": "انشُر الإشعار",
"publish_dialog_emoji_picker_show": "اختر رمزًا تعبيريًا",
"publish_dialog_priority_min": "الحد الأدنى للأولوية",
"publish_dialog_priority_low": "أولوية منخفضة",
"publish_dialog_priority_default": "الأولوية الافتراضية",
"publish_dialog_priority_high": "أولوية عالية",
"publish_dialog_base_url_label": "الرابط التشعبي للخدمة",
"publish_dialog_priority_max": "الأولوية القصوى",
"publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
"publish_dialog_title_label": "العنوان",
"publish_dialog_title_placeholder": "عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص",
"publish_dialog_message_label": "الرسالة",
"publish_dialog_message_placeholder": "اكتب رسالة هنا",
"publish_dialog_tags_label": "الوسوم",
"publish_dialog_priority_label": "الأولوية",
"publish_dialog_click_placeholder": "العنوان التشعبي URL الذي يتم فتحه عند النقر فوق الإشعار",
"publish_dialog_email_label": "البريد الإلكتروني",
"publish_dialog_filename_label": "اسم الملف",
"publish_dialog_attach_label": "الرابط التشعبي URL للمرفق",
"publish_dialog_filename_placeholder": "اسم ملف المرفق",
"publish_dialog_delay_label": "تأخير",
"publish_dialog_delay_reset": "إزالة تأخر التسليم",
"publish_dialog_chip_click_label": "انقر على عنوان URL",
"publish_dialog_chip_email_label": "إعادة التوجيه إلى البريد الإلكتروني",
"publish_dialog_chip_attach_file_label": "إرفاق ملف محلي",
"publish_dialog_chip_topic_label": "تغيير الموضوع",
"publish_dialog_button_cancel_sending": "إلغاء الإرسال",
"publish_dialog_button_send": "أرسل",
"publish_dialog_checkbox_publish_another": "نشر آخر",
"publish_dialog_attached_file_title": "الملف المرفق:",
"publish_dialog_attached_file_filename_placeholder": "اسم الملف المرفق",
"publish_dialog_attached_file_remove": "إزالة الملف المرفق",
"publish_dialog_drop_file_here": "قم بإسقاط ملف هنا",
"emoji_picker_search_placeholder": "البحث عن رمز تعبيري",
"emoji_picker_search_clear": "مسح البحث",
"subscribe_dialog_subscribe_title": "الإشتراك في الموضوع",
"subscribe_dialog_subscribe_use_another_label": "استخدام خادم آخر",
"subscribe_dialog_subscribe_base_url_label": "الرابط التشعبي URL للخدمة",
"subscribe_dialog_subscribe_button_subscribe": "اشترِك",
"subscribe_dialog_login_title": "تسجيل الدخول مطلوب",
"subscribe_dialog_login_username_label": "اسم المستخدم، على سبيل المثال phil",
"subscribe_dialog_login_password_label": "كلمة المرور",
"subscribe_dialog_login_button_login": "الولوج",
"subscribe_dialog_error_user_anonymous": "مجهول",
"prefs_notifications_title": "الإشعارات",
"prefs_notifications_sound_title": "صوت الإشعار",
"prefs_notifications_sound_no_sound": "لا صوت",
"prefs_notifications_min_priority_description_any": "عرض جميع الإشعارات، بغض النظر عن الأولوية",
"prefs_notifications_delete_after_title": "حذف الإشعارات",
"prefs_notifications_delete_after_never": "أبداً",
"prefs_notifications_delete_after_three_hours": "بعد ثلاث ساعات",
"prefs_notifications_delete_after_one_day": "بعد يوم واحد",
"prefs_notifications_delete_after_one_month": "بعد شهر واحد",
"prefs_notifications_delete_after_never_description": "لا يتم حذف الإشعارات تلقائيا مطلقا",
"prefs_notifications_delete_after_one_week_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
"prefs_notifications_delete_after_one_month_description": "يتم حذف الإشعارات تلقائيا بعد شهر واحد",
"prefs_users_table": "قائمة المستخدمين",
"prefs_users_edit_button": "تعديل المستخدم",
"prefs_users_table_user_header": "المستخدم",
"prefs_users_table_base_url_header": "الرابط التشعبي للخدمة",
"priority_default": "افتراضية",
"prefs_users_dialog_username_label": "اسم المستخدم، على سبيل المثال phil",
"prefs_users_dialog_button_cancel": "إلغاء",
"prefs_users_dialog_button_add": "اضافة",
"prefs_users_dialog_button_save": "حفظ",
"prefs_appearance_title": "المظهر",
"prefs_appearance_language_title": "اللغة",
"error_boundary_gathering_info": "جمع مزيد من المعلومات …",
"error_boundary_unsupported_indexeddb_title": "التصفح الخاص غير مدعوم",
"priority_high": "عالية",
"priority_max": "قصوى",
"error_boundary_title": "أوه لا ، لقد تحطم ntfy",
"prefs_users_delete_button": "حذف المستخدم",
"prefs_users_add_button": "إضافة مستخدم",
"prefs_notifications_min_priority_any": "مهما كانت الأولوية",
"prefs_notifications_delete_after_one_week": "بعد أسبوع واحد",
"prefs_notifications_delete_after_three_hours_description": "يتم حذف الإشعارات تلقائيا بعد ثلاث ساعات",
"prefs_notifications_delete_after_one_day_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
"prefs_users_title": "إدارة المستخدمين",
"prefs_users_dialog_title_add": "إضافة مستخدم",
"prefs_users_dialog_title_edit": "تعديل المستخدم",
"prefs_users_dialog_base_url_label": "عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh",
"publish_dialog_button_cancel": "إلغاء",
"publish_dialog_message_published": "تم نشر الإشعار",
"prefs_users_dialog_password_label": "كلمة المرور",
"publish_dialog_base_url_placeholder": "عنوان URL للخدمة، على سبيل المثال، https://example.com",
"publish_dialog_progress_uploading": "جارٍ التحميل…",
"publish_dialog_topic_label": "اسم الموضوع",
"publish_dialog_topic_reset": "إعادة تعيين الموضوع",
"publish_dialog_email_reset": "إزالة إعادة توجيه البريد الإلكتروني",
"publish_dialog_email_placeholder": "عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com",
"publish_dialog_other_features": "ميزات أخرى:",
"publish_dialog_chip_attach_url_label": "إرفاق ملف عن طريق عنوان URL",
"subscribe_dialog_subscribe_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
"prefs_notifications_sound_description_none": "لا تصدر الإشعارات أي صوت عند وصولها",
"publish_dialog_chip_delay_label": "تأخير التسليم",
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
"subscribe_dialog_login_button_back": "العودة",
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
"prefs_notifications_min_priority_title": "الحد الأدنى للأولوية",
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
"notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.",
"publish_dialog_click_label": "الرابط التشعبي URL للنقر",
"publish_dialog_tags_placeholder": "قائمة علامات مفصولة بفواصل، على سبيل المثال تحذير, srv1-backup",
"publish_dialog_attach_placeholder": "إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "إزالة عنوان URL للمرفق",
"subscribe_dialog_error_user_not_authorized": "المستخدم {{username}} غير مصرح به",
"common_save": "حفظ",
"common_add": "إضافة",
"signup_form_username": "إسم المستخدم",
"signup_form_confirm_password": "تأكيد كلمة المرور",
"login_title": "تسجيل الدخول إلى حسابك ntfy",
"login_form_button_submit": "الولوج",
"login_link_signup": "إنشاء حساب",
"login_disabled": "تم تعطيل تسجيل الدخول",
"action_bar_account": "الحساب",
"action_bar_change_display_name": "تغيير الإسم المعروض",
"signup_error_creation_limit_reached": "تم بلوغ حد إنشاء الحسابات",
"action_bar_reservation_add": "حجز الموضوع",
"action_bar_reservation_edit": "تغيير الحجز",
"action_bar_profile_title": "الملف التعريفي",
"action_bar_profile_settings": "اﻹعدادات",
"action_bar_profile_logout": "الخروج",
"action_bar_sign_in": "الولوج",
"action_bar_sign_up": "إنشاء حساب",
"nav_button_account": "الحساب",
"nav_upgrade_banner_label": "قم بالترقية إلى NTFY Pro",
"reserve_dialog_checkbox_label": "حجز الموضوع وإعداد الوصول",
"subscribe_dialog_subscribe_button_generate_topic_name": "توليد إسم",
"subscribe_dialog_error_topic_already_reserved": "الموضوع محجوز بالفعل",
"account_basics_title": "الحساب",
"account_basics_username_title": "إسم المستخدم",
"account_basics_username_description": "مرحبًا، هذا أنت ❤",
"account_basics_username_admin_tooltip": "أنت مدير",
"account_basics_password_title": "كلمة المرور",
"account_basics_password_description": "غيّر كلمة مرور حسابك",
"account_basics_password_dialog_title": "تغيير كلمة المرور",
"account_basics_password_dialog_current_password_label": "كلمة المرور الحالية",
"account_basics_password_dialog_new_password_label": "كلمة المرور الجديدة",
"account_basics_password_dialog_confirm_password_label": "تأكيد كلمة المرور",
"account_basics_password_dialog_button_submit": "تغيير كلمة المرور",
"account_basics_password_dialog_current_password_incorrect": "الكلمة السرية خاطئة",
"account_usage_title": "الإستخدام",
"account_usage_of_limit": "من {{limit}}",
"account_usage_unlimited": "غير محدود",
"account_basics_tier_title": "نوع الحساب",
"account_basics_tier_description": "مستوى قوة حسابك",
"account_basics_tier_admin": "مدير",
"account_basics_tier_free": "مجاني",
"account_basics_tier_upgrade_button": "الترقية إلى Pro",
"account_basics_tier_change_button": "تغيير",
"account_basics_tier_manage_billing_button": "إدارة الفوترة",
"account_usage_messages_title": "الرسائل المنشورة",
"account_usage_reservations_title": "المواضيع المحجوزة",
"account_usage_attachment_storage_title": "تخزين المرفقات",
"account_delete_title": "حذف الحساب",
"account_delete_description": "احذف حسابك نهائيا",
"account_delete_dialog_label": "كلمة المرور",
"account_upgrade_dialog_title": "تغيير فئة الحساب",
"account_upgrade_dialog_tier_features_messages": "{{messages}} رسائل يومية",
"account_upgrade_dialog_tier_features_emails": "{{emails}} من رسائل البريد الإلكتروني اليومية",
"account_upgrade_dialog_button_cancel": "إلغاء",
"account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك",
"account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك",
"account_tokens_title": "رموز الوصول",
"account_tokens_table_token_header": "الرمز المميز",
"account_tokens_table_last_access_header": "آخر وصول",
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
"account_tokens_table_current_session": "جلسة المتصفح الحالية",
"account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة",
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
"account_tokens_dialog_title_create": "إنشاء رمز مميز للوصول",
"account_tokens_dialog_title_edit": "تعديل الرمز المميز للوصول",
"account_tokens_dialog_title_delete": "حذف الرمز المميز للوصول",
"account_tokens_dialog_label": "التسمية، على سبيل المثال إشعارات الرادار",
"account_tokens_dialog_button_create": "إنشاء رمز مميز",
"account_tokens_dialog_button_update": "تحديث الرمز المميز",
"account_tokens_dialog_button_cancel": "إلغاء",
"account_tokens_dialog_expires_label": "تنتهي صلاحية الرمز المميز للوصول في",
"account_tokens_dialog_expires_unchanged": "اترك تاريخ انتهاء الصلاحية دون تغيير",
"account_tokens_dialog_expires_x_hours": "تنتهي صلاحية الرمز المميز في {{hours}} ساعات",
"account_tokens_dialog_expires_never": "لا تنتهي صلاحية الرمز المميز أبدًا",
"account_tokens_delete_dialog_title": "حذف الرمز المميز للوصول",
"account_tokens_delete_dialog_submit_button": "حذف الرمز المميز نهائيا",
"prefs_users_table_cannot_delete_or_edit": "لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول",
"prefs_reservations_add_button": "إضافة موضوع محجوز",
"prefs_reservations_table": "جدول المواضيع المحجوزة",
"prefs_reservations_table_topic_header": "الموضوع",
"prefs_reservations_table_access_header": "الوصول",
"prefs_reservations_table_everyone_deny_all": "أنا فقط من يستطيع النشر والاشتراك",
"prefs_reservations_table_everyone_write_only": "يمكنني النشر والاشتراك ، ويمكن للجميع النشر",
"prefs_reservations_table_everyone_read_write": "يمكن للجميع النشر والاشتراك",
"prefs_reservations_table_not_subscribed": "غير مشترك",
"prefs_reservations_dialog_title_edit": "تحرير الموضوع المحجوز",
"prefs_reservations_dialog_topic_label": "الموضوع",
"prefs_reservations_dialog_access_label": "الوصول",
"reservation_delete_dialog_action_delete_title": "حذف الرسائل والمرفقات المخزنة مؤقتا",
"reservation_delete_dialog_submit_button": "حذف الحجز",
"signup_title": "إنشاء حساب ntfy",
"common_cancel": "إلغاء",
"signup_form_password": "كلمة المرور",
"signup_already_have_account": "هل لديك حساب؟ قم بتسجيل الدخول!",
"signup_form_button_submit": "إنشاء حساب",
"signup_disabled": "تم تعطيل التسجيل",
"display_name_dialog_placeholder": "الإسم المعروض",
"display_name_dialog_title": "تغيير الإسم المعروض",
"account_basics_tier_basic": "أساسي",
"account_usage_emails_title": "رسائل البريد الإلكتروني المرسلة",
"account_usage_reservations_none": "لا توجد مواضيع محجوزة لهذا الحساب",
"account_usage_cannot_create_portal_session": "تعذر فتح بوابة الفوترة",
"account_delete_dialog_button_cancel": "إلغاء",
"account_delete_dialog_button_submit": "حذف الحساب نهائيا",
"account_upgrade_dialog_button_update_subscription": "تحديث الاشتراك",
"account_tokens_table_copied_to_clipboard": "تم نسخ الرمز المميز للوصول",
"prefs_reservations_title": "المواضيع المحجوزة",
"prefs_reservations_table_everyone_read_only": "يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك",
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
"action_bar_reservation_delete": "إزالة الحجز",
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر."
}

View File

@@ -114,8 +114,8 @@
"prefs_users_table_user_header": "Потребител",
"prefs_users_dialog_title_edit": "Промяна на потребител",
"prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh",
"prefs_users_dialog_button_cancel": "Отказ",
"prefs_users_dialog_button_save": "Запазване",
"common_cancel": "Отказ",
"common_save": "Запазване",
"prefs_appearance_language_title": "Език",
"subscribe_dialog_login_password_label": "Парола",
"subscribe_dialog_login_button_login": "Вход",
@@ -128,7 +128,7 @@
"prefs_users_dialog_title_add": "Добавяне на потребител",
"prefs_notifications_delete_after_one_month": "След един месец",
"prefs_users_dialog_username_label": "Потребител, напр. phil",
"prefs_users_dialog_button_add": "Добавяне",
"common_add": "Добавяне",
"error_boundary_title": "О, не, ntfy се срина",
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Следа от стека",
@@ -187,5 +187,46 @@
"prefs_users_table": "Таблица с потребители",
"prefs_users_edit_button": "Промяна на потребител",
"error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа",
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>."
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"signup_title": "Създаване на профил в ntfy",
"signup_form_username": "Потребител",
"signup_form_password": "Парола",
"signup_form_button_submit": "Регистриране",
"signup_form_toggle_password_visibility": "Превключване видимостта на паролата",
"signup_already_have_account": "Имате профил? Впишете се!",
"signup_error_username_taken": "Потребителското име {{username}} е заето",
"login_title": "Впишете се в профила си в ntfy",
"login_form_button_submit": "Вписване",
"login_link_signup": "Регистриране",
"login_disabled": "Вписването е изключено",
"action_bar_account": "Профил",
"action_bar_change_display_name": "Промяна на показваното име",
"action_bar_reservation_add": "Резервиране на тема",
"action_bar_reservation_delete": "Премахване на резервацията",
"action_bar_reservation_limit_reached": "Ограничението е достигнато",
"action_bar_profile_title": "Профил",
"action_bar_profile_settings": "Настройки",
"action_bar_profile_logout": "Изход",
"action_bar_sign_in": "Вписване",
"nav_button_account": "Профил",
"nav_upgrade_banner_label": "Надграждане до ntfy Pro",
"signup_form_confirm_password": "Парола отново",
"signup_disabled": "Регистрациите са затворени",
"signup_error_creation_limit_reached": "Достигнатео е ограничението за създаване на профили",
"display_name_dialog_title": "Промяна на показваното име",
"action_bar_reservation_edit": "Промяна на резервацията",
"action_bar_sign_up": "Регистриране",
"account_basics_title": "Профил",
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
"display_name_dialog_placeholder": "Наименование",
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
"account_basics_username_title": "Потребител",
"account_basics_username_description": "Хей, това сте вие ❤",
"account_basics_username_admin_tooltip": "Вие сте администратор",
"account_basics_password_title": "Парола",
"account_delete_dialog_label": "Парола"
}

View File

@@ -116,9 +116,9 @@
"prefs_users_add_button": "Přidat uživatele",
"prefs_users_table_user_header": "Uživatel",
"prefs_users_table_base_url_header": "URL služby",
"prefs_users_dialog_button_cancel": "Zrušit",
"prefs_users_dialog_button_add": "Přidat",
"prefs_users_dialog_button_save": "Uložit",
"common_cancel": "Zrušit",
"common_add": "Přidat",
"common_save": "Uložit",
"priority_min": "nejnižší",
"priority_low": "nízká",
"priority_default": "výchozí",
@@ -187,5 +187,158 @@
"prefs_notifications_sound_play": "Přehrát vybraný zvuk",
"prefs_users_table": "Tabulka uživatelů",
"notifications_attachment_file_document": "jiný dokument",
"publish_dialog_delay_reset": "Odebrat odložené doručení"
"publish_dialog_delay_reset": "Odebrat odložené doručení",
"signup_form_confirm_password": "Potvrdit heslo",
"signup_form_button_submit": "Zaregistrovat se",
"signup_form_username": "Uživatelské jméno",
"signup_form_toggle_password_visibility": "Přepnout viditelnost hesla",
"signup_already_have_account": "Už máte účet? Přihlašte se!",
"signup_error_username_taken": "Uživatelské jméno {{username}} je již obsazeno",
"signup_error_creation_limit_reached": "Dosažen limit pro vytvoření účtu",
"login_title": "Přihlaste se do svého ntfy účtu",
"login_form_button_submit": "Přihlásit se",
"login_link_signup": "Zaregistrovat se",
"login_disabled": "Přihlašování je zakázáno",
"action_bar_account": "Účet",
"action_bar_reservation_add": "Rezervovat téma",
"action_bar_reservation_edit": "Změnit rezervaci",
"action_bar_reservation_delete": "Odstranit rezervaci",
"action_bar_reservation_limit_reached": "Limit dosažen",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Nastavení",
"action_bar_profile_logout": "Odhlásit se",
"action_bar_sign_up": "Zaregistrovat se",
"nav_button_account": "Účet",
"nav_upgrade_banner_label": "Upgradovat na nfty Pro",
"nav_upgrade_banner_description": "Rezervace témat, více zpráv a emailů a větší přílohy",
"signup_title": "Vytvořit nfty účet",
"signup_form_password": "Heslo",
"display_name_dialog_description": "Nastaví alternativní název pro téma, které se zobrazí v seznamu odběrů. Toto pomáhá jednodušeji identifikovat témata s komplikovanými jmény.",
"action_bar_change_display_name": "Změnit zobrazovaný název",
"action_bar_sign_in": "Přihlásit se",
"alert_not_supported_context_description": "Oznámení jsou podporována pouze přes HTTPS. Toto je limitace <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_title": "Změnit zobrazovaný název",
"account_basics_password_title": "Heslo",
"account_basics_password_dialog_title": "Změna hesla",
"subscribe_dialog_error_topic_already_reserved": "Téma již rezervováno",
"subscribe_dialog_subscribe_button_generate_topic_name": "Generovat název",
"account_delete_dialog_description": "Dojde k trvalému odstranění vašeho účtu včetně všech dat uložených na serveru. Po smazání bude vaše uživatelské jméno po dobu 7 dnů nedostupné. Pokud opravdu chcete pokračovat, potvrďte prosím své heslo.",
"account_basics_tier_admin_suffix_with_tier": "(s úrovní {{tier}})",
"account_basics_tier_admin": "Administrátor",
"account_basics_tier_basic": "Základní",
"account_basics_tier_free": "Zdarma",
"account_basics_tier_admin_suffix_no_tier": "(žádná úroveň)",
"account_basics_tier_upgrade_button": "Přejít na verzi Pro",
"account_upgrade_dialog_cancel_warning": "Vaše <strong>předplatné se tímto zruší</strong> a váš účet se k datu {{date}} degraduje na nižší úroveň. K tomuto datu budou <strong>smazány</strong> rezervace témat i zprávy uložené v mezipaměti serveru.",
"account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Před změnou úrovně <strong>odstraňte alespoň {{počet}} rezervací</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.",
"reservation_delete_dialog_description": "Odstraněním rezervace se vzdáte vlastnictví tématu a umožníte ostatním, aby si ho rezervovali. Stávající zprávy a přílohy si můžete ponechat nebo je odstranit.",
"account_tokens_description": "Při publikování a odběru prostřednictvím rozhraní ntfy API používejte přístupové tokeny, abyste nemuseli odesílat přihlašovací údaje k účtu. Více informací najdete v <Link>dokumentaci</Link>.",
"account_tokens_table_copied_to_clipboard": "Přístupový token zkopírován",
"account_tokens_table_last_origin_tooltip": "Z IP adresy {{ip}}, klikněte pro vyhledání",
"account_tokens_dialog_button_cancel": "Zrušit",
"account_tokens_dialog_expires_never": "Token nikdy nevyprší",
"account_tokens_delete_dialog_description": "Před odstraněním přístupového tokenu se ujistěte, že jej aktivně nepoužívají žádné aplikace ani skripty. <strong>Tuto akci nelze vrátit zpět</strong>.",
"prefs_users_description_no_sync": "Uživatelé a hesla nejsou synchronizováni s vaším účtem.",
"prefs_users_table_cannot_delete_or_edit": "Nelze odstranit ani upravit přihlášeného uživatele",
"prefs_reservations_title": "Rezervovaná témata",
"prefs_reservations_description": "Zde si můžete rezervovat názvy témat pro osobní použití. Rezervací tématu získáte vlastnické právo k tématu a můžete definovat přístupová práva pro ostatní uživatele k tématu.",
"prefs_reservations_table_click_to_subscribe": "Kliknutím se přihlásíte k odběru",
"prefs_reservations_dialog_description": "Rezervací tématu získáte vlastnictví tématu a můžete definovat přístupová oprávnění pro ostatní uživatele.",
"prefs_reservations_dialog_access_label": "Přístup",
"reservation_delete_dialog_action_keep_title": "Zachovat zprávy a přílohy v mezipaměti",
"signup_disabled": "Přihlášení je zakázáno",
"display_name_dialog_placeholder": "Zobrazovaný název",
"reserve_dialog_checkbox_label": "Rezervace tématu a nastavení přístupu",
"account_basics_title": "Účet",
"account_basics_username_title": "Uživatelské jméno",
"account_basics_username_description": "Hej, to jsi ty ❤",
"account_basics_username_admin_tooltip": "Jste správce",
"account_basics_password_description": "Změna hesla k účtu",
"account_basics_password_dialog_current_password_label": "Současné heslo",
"account_basics_password_dialog_new_password_label": "Nové heslo",
"account_basics_password_dialog_confirm_password_label": "Potvrzení hesla",
"account_basics_password_dialog_button_submit": "Změnit heslo",
"account_basics_password_dialog_current_password_incorrect": "Nesprávné heslo",
"account_usage_title": "Použití",
"account_usage_of_limit": "z {{limit}}",
"account_usage_unlimited": "Neomezeně",
"account_usage_limits_reset_daily": "Limity používání se resetují denně o půlnoci (UTC)",
"account_basics_tier_title": "Typ účtu",
"account_basics_tier_description": "Úroveň oprávnění vašeho účtu",
"account_basics_tier_change_button": "Změnit",
"account_basics_tier_paid_until": "Předplatné zaplaceno do {{date}} a bude automaticky obnoveno",
"account_basics_tier_payment_overdue": "Vaše platba je po splatnosti. Aktualizujte prosím svůj způsob platby, jinak bude váš účet brzy degradován.",
"account_basics_tier_canceled_subscription": "Vaše předplatné bylo zrušeno a ke dni {{date}} bude převedeno na bezplatný účet.",
"account_basics_tier_manage_billing_button": "Správa vyúčtování",
"account_usage_messages_title": "Zveřejněné zprávy",
"account_usage_emails_title": "Odeslané e-maily",
"account_usage_reservations_title": "Rezervovaná témata",
"account_usage_reservations_none": "Žádná rezervovaná témata pro tento účet",
"account_usage_attachment_storage_title": "Úložiště příloh",
"account_usage_attachment_storage_description": "{{filesize}} na soubor, maže se po {{expiry}}",
"account_usage_basis_ip_description": "Statistiky a limity používání tohoto účtu jsou založeny na vaší IP adrese, takže mohou být sdíleny s ostatními uživateli. Výše uvedené limity jsou přibližné a vycházejí ze stávajících limitů.",
"account_usage_cannot_create_portal_session": "Nelze otevřít portál pro fakturaci",
"account_delete_title": "Odstranit účet",
"account_delete_description": "Trvale odstranit účet",
"account_delete_dialog_label": "Heslo",
"account_delete_dialog_button_cancel": "Zrušit",
"account_delete_dialog_button_submit": "Trvale odstranit účet",
"account_delete_dialog_billing_warning": "Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.",
"account_upgrade_dialog_title": "Změna úrovně účtu",
"account_upgrade_dialog_proration_info": "<strong>Prohlášení</strong>: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně účtován nebo vrácen v následující faktuře. Další fakturu obdržíte až na konci dalšího zúčtovacího období.",
"account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, <strong>odstraňte alespoň jednu rezervaci</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} rezervovaných témat",
"account_upgrade_dialog_tier_features_messages": "{{messages}} denních zpráv",
"account_upgrade_dialog_tier_features_emails": "{{emails}} denních e-mailů",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na soubor",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} celkový úložný prostor",
"account_upgrade_dialog_tier_selected_label": "Vybráno",
"account_upgrade_dialog_tier_current_label": "Současné",
"account_upgrade_dialog_button_cancel": "Zrušit",
"account_upgrade_dialog_button_redirect_signup": "Zaregistrovat se nyní",
"account_upgrade_dialog_button_pay_now": "Zaplatit a předplatit si",
"account_upgrade_dialog_button_cancel_subscription": "Zrušit předplatné",
"account_upgrade_dialog_button_update_subscription": "Aktualizovat předplatné",
"account_tokens_title": "Přístupové tokeny",
"account_tokens_table_token_header": "Token",
"account_tokens_table_last_access_header": "Poslední přístup",
"account_tokens_table_expires_header": "Vyprší",
"account_tokens_table_never_expires": "Nikdy nevyprší",
"account_tokens_table_current_session": "Současná relace prohlížeče",
"account_tokens_table_copy_to_clipboard": "Kopírování do schránky",
"account_tokens_table_label_header": "Popisek",
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
"account_tokens_table_create_token_button": "Vytvořit přístupový token",
"account_tokens_dialog_title_create": "Vytvoření přístupového tokenu",
"account_tokens_dialog_title_edit": "Úprava přístupového tokenu",
"account_tokens_dialog_title_delete": "Odstranění přístupového tokenu",
"account_tokens_dialog_label": "Popisek, např. Radarr notifications",
"account_tokens_dialog_button_create": "Vytvořit token",
"account_tokens_dialog_button_update": "Aktualizovat token",
"account_tokens_dialog_expires_label": "Platnost přístupového tokenu vyprší za",
"account_tokens_dialog_expires_unchanged": "Ponechat datum vypršení platnosti beze změny",
"account_tokens_dialog_expires_x_hours": "Token vyprší za {{hours}} hodin",
"account_tokens_dialog_expires_x_days": "Token vyprší za {{days}} dní",
"account_tokens_delete_dialog_title": "Odstranění přístupového tokenu",
"account_tokens_delete_dialog_submit_button": "Trvale odstranit token",
"prefs_reservations_limit_reached": "Dosáhli jste limitu rezervovaných témat.",
"prefs_reservations_add_button": "Přidat rezervované téma",
"prefs_reservations_edit_button": "Upravit přístup k tématu",
"prefs_reservations_delete_button": "Resetovat přístup k tématu",
"prefs_reservations_table": "Tabulka rezervovaných témat",
"prefs_reservations_table_topic_header": "Téma",
"prefs_reservations_table_access_header": "Přístup",
"prefs_reservations_table_everyone_deny_all": "Pouze já mohu publikovat a přihlásit se k odběru",
"prefs_reservations_table_everyone_read_only": "Mohu publikovat a přihlásit se k odběru, kdokoli se může přihlásit k odběru",
"prefs_reservations_table_everyone_write_only": "Mohu publikovat a přihlásit se k odběru, kdokoli může publikovat",
"prefs_reservations_table_everyone_read_write": "Kdokoli může publikovat a přihlásit se k odběru",
"prefs_reservations_table_not_subscribed": "Odběr není přihlášen",
"prefs_reservations_dialog_title_add": "Rezervovat téma",
"prefs_reservations_dialog_title_edit": "Úprava rezervovaného tématu",
"prefs_reservations_dialog_title_delete": "Odstranění rezervovaného tématu",
"prefs_reservations_dialog_topic_label": "Téma",
"reservation_delete_dialog_action_keep_description": "Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.",
"reservation_delete_dialog_action_delete_title": "Odstranění zpráv a příloh uložených v mezipaměti",
"reservation_delete_dialog_action_delete_description": "Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.",
"reservation_delete_dialog_submit_button": "Odstranit rezervaci"
}

View File

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

View File

@@ -15,9 +15,9 @@
"prefs_notifications_min_priority_max_only": "Nur höchste Priorität",
"prefs_notifications_delete_after_never": "Nie",
"prefs_users_dialog_password_label": "Kennwort",
"prefs_users_dialog_button_cancel": "Abbrechen",
"prefs_users_dialog_button_add": "Hinzufügen",
"prefs_users_dialog_button_save": "Speichern",
"common_cancel": "Abbrechen",
"common_add": "Hinzufügen",
"common_save": "Speichern",
"prefs_appearance_language_title": "Sprache",
"notifications_none_for_any_description": "Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.",
"publish_dialog_message_placeholder": "Gib hier eine Nachricht ein",
@@ -82,7 +82,7 @@
"publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_placeholder": "Dateiname des Anhangs",
"publish_dialog_delay_label": "Verzögerung",
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@example.com",
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z. B. phil@example.com",
"publish_dialog_chip_click_label": "Klick-URL",
"publish_dialog_button_cancel_sending": "Senden abbrechen",
"publish_dialog_drop_file_here": "Datei hierher ziehen",
@@ -187,5 +187,158 @@
"publish_dialog_emoji_picker_show": "Emoji wählen",
"publish_dialog_topic_reset": "Thema zurücksetzen",
"publish_dialog_attach_reset": "angehängte URL entfernen",
"publish_dialog_click_reset": "Klick-URL entfernen"
"publish_dialog_click_reset": "Klick-URL entfernen",
"account_tokens_delete_dialog_description": "Stelle vor dem Löschen eines Access-Tokens sicher, dass keine Anwendung oder Skripte dieses Token verwenden. <strong>Diese Aktion kann nicht rückgängig gemacht werden</strong>.",
"account_upgrade_dialog_cancel_warning": "Dies wird <strong>Dein Abo stornieren</strong> und Dein Konto am {{date}} herabstufen. An diesem Datum werden reservierte Themen und auch auf dem Server gecachte Nachrichten <strong>gelöscht</strong>.",
"prefs_reservations_table_everyone_read_write": "Jeder kann veröffentlichen und lesen",
"prefs_reservations_table_everyone_read_only": "Ich kann veröffentlichen und lesen, jeder kann lesen",
"prefs_reservations_table_access_header": "Zugriff",
"account_tokens_dialog_button_cancel": "Abbrechen",
"account_tokens_dialog_expires_x_hours": "Token verfällt in {{hours}} Stunden",
"account_tokens_dialog_expires_never": "Token verfällt nie",
"signup_form_username": "Benutzername",
"signup_form_button_submit": "Konto anlegen",
"signup_already_have_account": "Du hast schon ein Konto? Melde Dich an!",
"signup_disabled": "Die Anmeldung ist deaktiviert",
"login_title": "Melde Dich mit Deinem ntfy-Konto an",
"login_form_button_submit": "Anmelden",
"login_link_signup": "Konto erstellen",
"login_disabled": "Anmeldung ist deaktiviert",
"action_bar_account": "Konto",
"action_bar_change_display_name": "Anzeigenamen ändern",
"action_bar_reservation_add": "Thema reservieren",
"action_bar_reservation_edit": "Reservierung ändern",
"action_bar_reservation_delete": "Reservierung löschen",
"action_bar_reservation_limit_reached": "Grenze erreicht",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Einstellungen",
"action_bar_profile_logout": "Abmelden",
"action_bar_sign_in": "Anmelden",
"signup_form_password": "Kennwort",
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
"nav_button_account": "Konto",
"nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & Emails, größere Anhänge",
"display_name_dialog_title": "Anzeigennamen ändern",
"display_name_dialog_placeholder": "Anzeigename",
"reserve_dialog_checkbox_label": "Thema reservieren und Zugriffsrechte konfigurieren",
"subscribe_dialog_error_topic_already_reserved": "Thema ist bereits reserviert",
"account_basics_username_title": "Benutzername",
"account_basics_username_description": "Hey, das bist Du ❤",
"account_basics_password_description": "Konto-Kennwort ändern",
"account_basics_password_dialog_title": "Kennwort ändern",
"account_basics_password_dialog_current_password_label": "Aktuelles Kennwort",
"account_basics_password_dialog_new_password_label": "Neues Kennwort",
"account_basics_password_dialog_confirm_password_label": "Kennwort bestätigen",
"account_basics_password_dialog_current_password_incorrect": "Kennwort falsch",
"account_usage_title": "Verbrauch",
"account_usage_of_limit": "von {{limit}}",
"account_usage_unlimited": "unbegrenzt",
"account_usage_limits_reset_daily": "Verbrauchslimits werden täglich um Mitternacht (UTC) zurückgesetzt",
"account_basics_password_title": "Kennwort",
"account_basics_tier_description": "Der Funktionsumfang Deines Konto-Levels",
"account_basics_tier_admin_suffix_with_tier": "(mit Level {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(kein Level)",
"account_basics_tier_admin": "Admin",
"account_basics_tier_basic": "Basic",
"account_basics_tier_free": "Kostenlos",
"account_basics_tier_paid_until": "Abo bezahlt bis {{date}} mit automatischer Verlängerung",
"account_basics_tier_payment_overdue": "Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.",
"account_basics_tier_manage_billing_button": "Zahlung verwalten",
"account_usage_messages_title": "Veröffentlichte Nachrichten",
"account_usage_emails_title": "Gesendete Emails",
"account_usage_reservations_title": "Reservierte Themen",
"account_usage_reservations_none": "Keine reservierten Themen für dieses Konto",
"account_usage_attachment_storage_title": "Speicherplatz für Anhänge",
"account_usage_attachment_storage_description": "{{filesize}} pro Datei, Löschung nach {{expiry}}",
"account_usage_cannot_create_portal_session": "Kann Abrechnungsportal nicht öffnen",
"account_delete_title": "Konto löschen",
"account_delete_description": "Konto endgültig löschen",
"account_delete_dialog_label": "Kennwort",
"account_delete_dialog_button_cancel": "Abbrechen",
"account_delete_dialog_button_submit": "Lösche mein Konto endgültig",
"account_basics_tier_change_button": "Wechseln",
"account_basics_tier_canceled_subscription": "Dein Abo wurde storniert und wird am {{date}} auf ein kostenloses Konto herabgestuft.",
"account_usage_basis_ip_description": "Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.",
"account_delete_dialog_billing_warning": "Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.",
"account_upgrade_dialog_title": "Konto-Level ändern",
"account_upgrade_dialog_proration_info": "<strong>Anrechnung</strong>: Wenn Du zwischen kostenpflichtigen Leveln wechselst wir die Differenz bei der nächsten Abrechnung nachberechnet oder erstattet. Du erhältst bis zum Ende der Abrechnungsperiode keine neue Rechnung.",
"account_upgrade_dialog_reservations_warning_one": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
"account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reservierte Themen",
"account_upgrade_dialog_tier_features_messages": "{{messages}} Nachrichten pro Tag",
"account_upgrade_dialog_tier_features_emails": "{{emails}} Emails pro Tag",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz",
"account_upgrade_dialog_tier_selected_label": "Ausgewählt",
"account_upgrade_dialog_tier_current_label": "Aktuell",
"account_upgrade_dialog_button_cancel": "Abbrechen",
"account_upgrade_dialog_button_redirect_signup": "Jetzt ein Konto anlegen",
"account_upgrade_dialog_button_pay_now": "Jetzt bezahlen und abonnieren",
"account_upgrade_dialog_button_cancel_subscription": "Abo stornieren",
"account_upgrade_dialog_button_update_subscription": "Abo aktualisieren",
"account_tokens_title": "Access-Token",
"account_tokens_description": "Verwende Access-Token zum Versenden und Empfangen über die ntfy-API, um nicht Deine Zugangsdaten verwenden zu müssen. Lies die <Link>Dokumentation</Link> für mehr Info.",
"account_tokens_table_token_header": "Token",
"account_tokens_table_label_header": "Bezeichnung",
"account_tokens_table_last_access_header": "Letzter Zugriff",
"account_tokens_table_expires_header": "Verfällt",
"account_tokens_table_never_expires": "Verfällt nie",
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
"account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren",
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
"account_tokens_table_create_token_button": "Access-Token erzeugen",
"account_tokens_table_last_origin_tooltip": "Von IP-Adresse {{ip}}, klicke zum Nachschlagen",
"account_tokens_dialog_title_create": "Access-Token erzeugen",
"account_tokens_dialog_title_edit": "Access-Token bearbeiten",
"account_tokens_dialog_title_delete": "Access-Token löschen",
"account_tokens_dialog_label": "Bezeichnung, z.B. Radarr Benachrichtigungen",
"account_tokens_dialog_button_create": "Token erzeugen",
"account_tokens_dialog_button_update": "Token aktualisieren",
"account_tokens_dialog_expires_label": "Access-Token verfällt in",
"account_tokens_dialog_expires_unchanged": "Verfallsdatum nicht ändern",
"account_tokens_dialog_expires_x_days": "Token verfällt in {{days}} Tagen",
"account_tokens_delete_dialog_title": "Access-Token löschen",
"account_tokens_delete_dialog_submit_button": "Token endgültig löschen",
"prefs_users_description_no_sync": "Benutzernamen und Kennwörter werden nicht im Konto synchronisiert.",
"prefs_users_table_cannot_delete_or_edit": "Angemeldeter Benutzer kann nicht gelöscht oder bearbeitet werden",
"prefs_reservations_title": "Reservierte Themen",
"prefs_reservations_description": "Du kannst hier Themen-Namen für Deine persönliche Verwendung reservieren. Das Reservieren eines Themas macht Dich zum Besitzer des Themas. Du kannst damit auch Zugriffsrechte für andere Benutzer auf das Thema festlegen.",
"prefs_reservations_limit_reached": "Du hast Dein Limit an reservierten Themen erreicht.",
"prefs_reservations_add_button": "Reserviertes Thema hinzufügen",
"prefs_reservations_edit_button": "Zugriff auf Thema bearbeiten",
"prefs_reservations_delete_button": "Zugriff auf Thema zurücksetzen",
"prefs_reservations_table": "Übersicht reservierter Themen",
"prefs_reservations_table_topic_header": "Thema",
"prefs_reservations_table_everyone_deny_all": "Nur kann veröffentlichen und lesen",
"prefs_reservations_table_everyone_write_only": "Ich kann veröffentlichen und lesen, jeder kann veröffentlichen",
"prefs_reservations_table_not_subscribed": "Nicht abonniert",
"prefs_reservations_table_click_to_subscribe": "Klicken um zu abonnieren",
"prefs_reservations_dialog_title_add": "Thema reservieren",
"prefs_reservations_dialog_title_edit": "Reserviertes Thema bearbeiten",
"prefs_reservations_dialog_title_delete": "Thema-Reservierung löschen",
"prefs_reservations_dialog_description": "Ein Thema zu reservieren macht Dich zum Besitzer des Themas, und erlaubt Dir Zugriffsrechte für andere auf dieses Thema festzulegen.",
"prefs_reservations_dialog_topic_label": "Thema",
"prefs_reservations_dialog_access_label": "Zugriff",
"reservation_delete_dialog_description": "Mit dem Löschen einer Reservierung gibst du den Besitz des Themas auf und ermöglichst anderen, es zu reservieren. Du kannst vorhandene Nachrichten und Dateien behalten oder löschen.",
"reservation_delete_dialog_action_keep_title": "Behalte gecachte Nachrichten und Dateien",
"reservation_delete_dialog_action_keep_description": "Nachrichten und Dateien, die auf dem Server gecached sind, werden für alle sichtbar die den Themen-Namen kennen.",
"reservation_delete_dialog_action_delete_title": "Löschen gecachte Nachrichten und Dateien",
"reservation_delete_dialog_action_delete_description": "Gecachte Nachrichten und Dateien werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"reservation_delete_dialog_submit_button": "Reservierung löschen",
"account_basics_password_dialog_button_submit": "Kennwort ändern",
"account_basics_tier_title": "Kontotyp",
"account_basics_tier_upgrade_button": "Upgrade auf Pro",
"account_delete_dialog_description": "Hiermit wird Dein Konto endgültig gelöscht, inklusive aller Daten auf dem Server. Nach dem Löschen wird Dein Benutzername für 7 Tage gesperrt sein. Wenn Du fortfahren willst, bestätige das durch Eingabe Deines Kennwortes.",
"signup_form_confirm_password": "Kennwort wiederholen",
"signup_title": "Erstelle ein ntfy-Konto",
"signup_error_username_taken": "Benutzername {{username}} ist bereits vergeben",
"signup_error_creation_limit_reached": "Grenze der Account-Erstellung erreicht",
"subscribe_dialog_subscribe_button_generate_topic_name": "Namen erzeugen",
"account_basics_title": "Konto",
"action_bar_sign_up": "Konto erstellen",
"nav_upgrade_banner_label": "Upgrade auf ntfy Pro",
"alert_not_supported_context_description": "Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_description": "Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.",
"account_basics_username_admin_tooltip": "Du bist Admin"
}

View File

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

View File

@@ -101,8 +101,8 @@
"prefs_users_add_button": "Añadir usuario",
"prefs_users_dialog_title_edit": "Editar usuario",
"prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh",
"prefs_users_dialog_button_add": "Añadir",
"prefs_users_dialog_button_save": "Guardar",
"common_add": "Añadir",
"common_save": "Guardar",
"prefs_appearance_title": "Apariencia",
"prefs_appearance_language_title": "Idioma",
"error_boundary_title": "Oh no, ntfy tuvo un error",
@@ -134,7 +134,7 @@
"prefs_users_dialog_password_label": "Contraseña",
"error_boundary_description": "Obviamente, esto no debería ocurrir. Lo sentimos mucho.<br/>Si tienes un minuto, por favor <githubLink>informa de esto en GitHub</githubLink>, o avísanos vía <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
"prefs_users_dialog_title_add": "Añadir usuario",
"prefs_users_dialog_button_cancel": "Cancelar",
"common_cancel": "Cancelar",
"prefs_users_dialog_username_label": "Nombre de usuario, ej. phil",
"priority_max": "máx",
"priority_high": "alta",
@@ -187,5 +187,58 @@
"prefs_users_table": "Tabla de usuarios",
"prefs_users_edit_button": "Editar usuario",
"prefs_users_delete_button": "Eliminar usuario",
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada"
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada",
"action_bar_profile_title": "Perfil",
"action_bar_profile_settings": "Configuración",
"signup_title": "Crear una cuenta ntfy",
"signup_form_username": "Nombre de usuario",
"signup_form_password": "Contraseña",
"signup_form_confirm_password": "Confirmar contraseña",
"signup_form_button_submit": "Registro",
"signup_form_toggle_password_visibility": "Alternar la visibilidad de la contraseña",
"signup_already_have_account": "¿Ya tienes una cuenta? ¡Iniciar sesión!",
"signup_disabled": "El registro está deshabilitado",
"signup_error_username_taken": "El nombre de usuario {{username}} ya está en uso",
"signup_error_creation_limit_reached": "Límite de creación de cuenta alcanzado",
"login_title": "Inicie sesión en su cuenta ntfy",
"login_form_button_submit": "Iniciar sesión",
"login_link_signup": "Registro",
"login_disabled": "Inicio de sesión deshabilitado",
"action_bar_account": "Cuenta",
"action_bar_change_display_name": "Cambiar nombre de usuario",
"action_bar_reservation_add": "Reservar tema",
"action_bar_reservation_edit": "Modificar reserva",
"action_bar_reservation_delete": "Quitar reserva",
"action_bar_reservation_limit_reached": "Límite alcanzado",
"action_bar_profile_logout": "Cerrar sesión",
"action_bar_sign_in": "Iniciar sesión",
"action_bar_sign_up": "Registro",
"nav_button_account": "Cuenta",
"nav_upgrade_banner_label": "Actualizar a ntfy Pro",
"nav_upgrade_banner_description": "Reserve temas, más mensajes y correos electrónicos, y archivos adjuntos más grandes",
"display_name_dialog_title": "Cambiar el nombre para mostrar",
"display_name_dialog_description": "Establezca un nombre alternativo para un tópico que se muestra en la lista de suscripciones. Esto ayuda a identificar más fácilmente los temas con nombres complicados.",
"display_name_dialog_placeholder": "Nombre para mostrar",
"account_basics_username_admin_tooltip": "Eres Administrador",
"account_basics_password_description": "Cambiar la contraseña de tu cuenta",
"account_basics_password_dialog_confirm_password_label": "Confirmar contraseña",
"account_basics_password_dialog_button_submit": "Cambiar contraseña",
"account_basics_password_dialog_current_password_incorrect": "Contraseña incorrecta",
"account_usage_unlimited": "Ilimitado",
"account_usage_title": "Uso",
"account_usage_of_limit": "de {{límite}}",
"account_usage_limits_reset_daily": "Los límites de uso se restablecen diariamente a la medianoche (UTC)",
"account_basics_tier_description": "Nivel de poder de tu cuenta",
"account_basics_tier_admin": "Administrador",
"alert_not_supported_context_description": "Las notificaciones sólo se admiten a través de HTTPS. Esta es una limitante de la <mdnLink>API de notificaciones</mdnLink> .",
"reserve_dialog_checkbox_label": "Reservar tópico y configurar el acceso",
"subscribe_dialog_subscribe_button_generate_topic_name": "Generar nombre",
"subscribe_dialog_error_topic_already_reserved": "Tópico ya reservado",
"account_basics_title": "Cuenta",
"account_basics_username_title": "Nombre de usuario",
"account_basics_username_description": "Hey, ese eres tú ❤",
"account_basics_password_title": "Contraseña",
"account_basics_password_dialog_title": "Cambiar contraseña",
"account_basics_password_dialog_current_password_label": "Contraseña actual",
"account_basics_password_dialog_new_password_label": "Contraseña nueva"
}

View File

@@ -7,7 +7,7 @@
"message_bar_type_message": "Tapez un message ici",
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
"notifications_attachment_link_expires": "le lien expire {{date}}",
"message_bar_error_publishing": "Notification d'erreur de publication",
"message_bar_error_publishing": "Erreur lors de la publication de la notification",
"nav_button_all_notifications": "Toutes les notifications",
"nav_button_settings": "Paramètres",
"nav_button_documentation": "Documentation",
@@ -79,8 +79,8 @@
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
"subscribe_dialog_login_title": "Connexion nécessaire",
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
"prefs_users_dialog_button_cancel": "Annuler",
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
"common_cancel": "Annuler",
"error_boundary_button_copy_stack_trace": "Copier la trace d'appels",
"publish_dialog_attached_file_title": "Fichier joint :",
"publish_dialog_checkbox_publish_another": "Publier un autre",
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
@@ -128,8 +128,8 @@
"prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.",
"prefs_users_table_user_header": "Utilisateur",
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
"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>.",
"common_add": "Ajouter",
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
"error_boundary_stack_trace": "Trace de pile d'appels",
"error_boundary_gathering_info": "Récupérer plus d'information…",
@@ -152,7 +152,7 @@
"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",
"common_save": "Enregistrer",
"notifications_new_indicator": "Nouvelle notification",
"publish_dialog_delay_reset": "Retirer le délai de réception",
"notifications_list_item": "Notification",
@@ -187,5 +187,158 @@
"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"
"publish_dialog_attached_file_remove": "Retirer le fichier joint",
"signup_form_password": "Mot de passe",
"signup_form_confirm_password": "Confirmation du mot de passe",
"signup_disabled": "L'inscription est désactivée",
"signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé",
"signup_error_creation_limit_reached": "Limite de création de comptes atteinte",
"login_title": "Se connecter à son compte Ntfy",
"login_form_button_submit": "Connexion",
"login_link_signup": "S'inscrire",
"login_disabled": "La connection est désactivée",
"action_bar_account": "Compte",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Paramètres",
"action_bar_sign_in": "Connexion",
"action_bar_sign_up": "Inscription",
"nav_button_account": "Compte",
"signup_title": "Créer un compte Ntfy",
"signup_form_username": "Identifiant",
"signup_form_button_submit": "S'inscrire",
"signup_already_have_account": "Vous avez déjà un compte ? Connectez-vous !",
"action_bar_profile_logout": "Se déconnecter",
"signup_form_toggle_password_visibility": "Afficher le mot de passe",
"action_bar_change_display_name": "Changer le nom affiché",
"prefs_reservations_table_click_to_subscribe": "Cliquer pour s'abonner",
"account_tokens_table_cannot_delete_or_edit": "Impossible d'éditer ou de supprimer le jeton de la session actuelle",
"account_tokens_dialog_button_cancel": "Annuler",
"prefs_users_table_cannot_delete_or_edit": "Impossible de supprimer ou de modifier un utilisateur connecté",
"prefs_users_description_no_sync": "Les utilisateurs et les mots de passe ne sont pas synchronisés avec votre compte.",
"account_tokens_dialog_button_update": "Mettre à jour un jeton",
"nav_upgrade_banner_description": "Réservation de sujets, plus de messages et d'emails, et des pièces jointes plus larges",
"display_name_dialog_description": "Mettre un nom supplémentaire pour un sujet qui est affiché dans la liste des abonnements. Cela aide à identifier plus facilement les sujets ayant des noms compliqués.",
"account_usage_basis_ip_description": "Les statistiques d'utilisation et les limites pour ce compte sont basées sur votre adresse IP, donc elles peuvent être partagées avec d'autres utilisateurs. Les limites affichées plus haut sont approximativement basées sur les limites de débit existantes.",
"action_bar_reservation_add": "Réserver un sujet",
"action_bar_reservation_edit": "Changer la réservation",
"action_bar_reservation_delete": "Supprimer la réservation",
"action_bar_reservation_limit_reached": "Limite atteinte",
"nav_upgrade_banner_label": "Passer à ntfy Pro",
"display_name_dialog_title": "Changer le nom affiché",
"reserve_dialog_checkbox_label": "Réserver un sujet et en configurer l'accès",
"display_name_dialog_placeholder": "Nom affiché",
"subscribe_dialog_subscribe_button_generate_topic_name": "Générer un nom",
"subscribe_dialog_error_topic_already_reserved": "Sujet déjà réservé",
"account_basics_title": "Compte",
"account_basics_username_title": "Nom d'utilisateur",
"account_basics_username_description": "Hé, c'est toi ❤",
"account_basics_username_admin_tooltip": "Vous êtes Administrateur",
"account_basics_password_title": "Mot de passe",
"account_basics_password_description": "Changer le mot de passe de votre compte",
"account_basics_password_dialog_title": "Changer le mot de passe",
"account_basics_password_dialog_current_password_label": "Mot de passe actuel",
"account_basics_password_dialog_new_password_label": "Nouveau mot de passe",
"account_basics_password_dialog_confirm_password_label": "Confirmer le mot de passe",
"account_basics_password_dialog_button_submit": "Changer le mot de passe",
"account_basics_password_dialog_current_password_incorrect": "Mot de passe incorrect",
"account_usage_title": "Utilisation",
"account_usage_of_limit": "sur {{limit}}",
"account_usage_unlimited": "Illimité",
"account_usage_limits_reset_daily": "Les limites d'utilisation sont réinitialisées chaque jour à minuit (UTC)",
"account_basics_tier_title": "Type de compte",
"account_basics_tier_description": "Le niveau de puissance de votre compte",
"account_basics_tier_admin": "Administrateur",
"account_basics_tier_admin_suffix_with_tier": "(avec le tarif {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(pas de tarif)",
"account_basics_tier_free": "Gratuit",
"account_basics_tier_upgrade_button": "Passer à Pro",
"account_basics_tier_change_button": "Changer",
"account_basics_tier_paid_until": "Abonnement payé jusqu'à {{date}}, et va être automatiquement renouvelé",
"account_basics_tier_canceled_subscription": "Votre abonnement a été annulé et va être rétrogradé vers un compte gratuit le {{date}}.",
"account_basics_tier_manage_billing_button": "Gérer la facturation",
"account_usage_messages_title": "Messages publiés",
"account_usage_emails_title": "Emails envoyés",
"account_usage_reservations_title": "Sujets réservés",
"account_usage_reservations_none": "Pas de sujet réservé pour ce compte",
"account_usage_attachment_storage_title": "Stockage des pièces jointes",
"account_usage_attachment_storage_description": "{{filesize}} par fichier, supprimé après {{expiry}}",
"account_usage_cannot_create_portal_session": "Impossible d'ouvrir le portail de facturation",
"account_delete_title": "Supprimer le compte",
"account_delete_description": "Supprimer définitivement votre compte",
"account_basics_tier_basic": "Basique",
"account_delete_dialog_description": "Cela supprimera définitivement votre compte, ainsi que toutes les données qui sont stockées sur le serveur. Après suppression, votre nom d'utilisateur sera indisponible pendant 7 jours. Si vous voulez vraiment faire cela, veuillez le confirmer en mettant votre mot de passe dans le champ ci-dessous.",
"account_delete_dialog_label": "Mot de passe",
"account_delete_dialog_button_cancel": "Annuler",
"account_delete_dialog_button_submit": "Supprimer définitivement le compte",
"account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.",
"account_upgrade_dialog_title": "Changer le tarif du compte",
"account_upgrade_dialog_proration_info": "<strong>Facturation</strong> : Lors d'un changement entre un plan payant et un autre, la différence de prix sera créditée ou remboursée sur la prochaine facture. Vous ne recevrez pas d'autre facture avant la fin de la prochaine période de facturation.",
"account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins {{count}} sujets réservés</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} sujets réservés",
"account_upgrade_dialog_tier_features_messages": "{{messages}} messages journaliers",
"account_upgrade_dialog_tier_features_emails": "{{emails}} emails journaliers",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} par fichier",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} stockage total",
"account_upgrade_dialog_tier_selected_label": "Sélectionné",
"account_upgrade_dialog_tier_current_label": "Actuel",
"account_upgrade_dialog_button_cancel": "Annuler",
"account_upgrade_dialog_button_redirect_signup": "S'inscrire maintenant",
"account_upgrade_dialog_button_pay_now": "Payer maintenant et s'abonner",
"account_upgrade_dialog_button_cancel_subscription": "Annuler l'abonnement",
"account_upgrade_dialog_button_update_subscription": "Mettre à jour l'abonnement",
"account_tokens_title": "Jetons d'accès",
"account_tokens_table_token_header": "Jeton",
"account_tokens_table_label_header": "Étiquette",
"account_tokens_table_last_access_header": "Dernier accès",
"account_tokens_table_expires_header": "Expire",
"account_tokens_table_never_expires": "N'expire jamais",
"account_tokens_table_current_session": "Session de navigation actuelle",
"account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier",
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
"account_tokens_table_create_token_button": "Créer un jeton d'accès",
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
"account_tokens_dialog_title_create": "Créer un jeton d'accès",
"account_tokens_dialog_title_edit": "Modifier le jeton d'accès",
"account_tokens_dialog_title_delete": "Supprimer le jeton d'accès",
"account_tokens_dialog_label": "Étiquette, par ex. Notifications Radarr",
"account_tokens_dialog_button_create": "Créer un jeton",
"account_tokens_dialog_expires_label": "Le jeton d'accès expire dans",
"account_tokens_dialog_expires_unchanged": "Laisser la date d'expiration inchangée",
"account_tokens_dialog_expires_x_hours": "Le jeton expire dans {{hours}} heures",
"account_tokens_dialog_expires_x_days": "Le jeton expire dans {{days}} jours",
"account_tokens_dialog_expires_never": "Le jeton n'expire jamais",
"account_tokens_delete_dialog_title": "Supprimer le jeton d'accès",
"account_tokens_delete_dialog_submit_button": "Supprimer définitivement le jeton",
"prefs_reservations_title": "Sujets réservés",
"prefs_reservations_limit_reached": "Vous avez atteint votre limite de réservation de sujets.",
"prefs_reservations_add_button": "Ajouter un sujet réservé",
"prefs_reservations_edit_button": "Modifier l'accès d'un sujet",
"prefs_reservations_delete_button": "Réinitialiser l'accès d'un sujet",
"prefs_reservations_table": "Tableau des sujets réservés",
"prefs_reservations_table_topic_header": "Sujet",
"prefs_reservations_table_access_header": "Accès",
"prefs_reservations_table_everyone_deny_all": "Seulement moi peut publier et m'abonner",
"prefs_reservations_table_everyone_read_only": "Je peux publier et m'abonner, tout le monde peut s'abonner",
"prefs_reservations_table_everyone_write_only": "Je peux publier et m'abonner, tout le monde peut publier",
"prefs_reservations_table_everyone_read_write": "Tout le monde peut publier et s'abonner",
"prefs_reservations_table_not_subscribed": "Pas abonné",
"prefs_reservations_dialog_title_add": "Réserver un sujet",
"prefs_reservations_dialog_title_edit": "Modifier un sujet réservé",
"prefs_reservations_dialog_title_delete": "Supprimé un sujet réservé",
"prefs_reservations_dialog_description": "Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.",
"prefs_reservations_dialog_topic_label": "Sujet",
"prefs_reservations_dialog_access_label": "Accès",
"reservation_delete_dialog_description": "Supprimer un sujet réservé abandonne la propriété sur le sujet et permet aux autres de le réserver. Vous pouvez garder ou supprimer les messages et pièces jointes existantes.",
"reservation_delete_dialog_action_keep_title": "Garder les messages et pièces jointes mises en cache",
"reservation_delete_dialog_action_keep_description": "Les messages et pièces jointes qui sont dans le cache du serveur deviendront visibles publiquement pour les personnes ayant connaissance du nom du sujet.",
"reservation_delete_dialog_action_delete_title": "Supprimer les messages et pièces jointes mises en cache",
"reservation_delete_dialog_action_delete_description": "Les messages et pièces jointes mises en cache seront définitivement supprimées. Cette action ne peut pas être annulée.",
"reservation_delete_dialog_submit_button": "Supprimer un sujet réservé",
"alert_not_supported_context_description": "Les notifications ne sont supportées qu'en HTTPS. C'est une limitation de la <mdnLink>Notifications API</mdnLink>.",
"account_basics_tier_payment_overdue": "Votre paiement est en retard. Veuillez mettre à jour votre méthode de paiement, ou votre compte va bientôt être rétrogradé.",
"account_upgrade_dialog_cancel_warning": "Cela va <strong>annuler votre abonnement</strong> et rétrograder votre compte le {{date}}. Ce jour là, les sujets réservés ainsi que tous les messages dans le cache du serveur <strong>seront supprimés</strong>.",
"account_upgrade_dialog_reservations_warning_one": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins un sujet réservé</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
"account_tokens_description": "Utilisez des jetons d'accès lors de la publication ou de l'abonnement via l'API de ntfy, afin d'éviter d'envoyer vos identifiants de compte. Regardez la <Link>documentation</Link> pour en savoir plus.",
"account_tokens_delete_dialog_description": "Avant de supprimer un jeton d'accès, assurez-vous qu'aucune application ou script ne soit en train de l'utiliser. <strong>Cette action ne peut pas être annulée</strong>.",
"prefs_reservations_description": "Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs."
}

View File

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

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