Compare commits

..
Author SHA1 Message Date
binwiederhier 3f56dae54a Template exec context, redone 2026-07-10 13:18:11 +02:00
binwiederhier 1e4e3b6e36 Remove unreachable link 2026-07-09 23:07:07 +02:00
binwiederhier 75c687de1c Bump Android pre-release 2026-07-09 22:50:11 +02:00
binwiederhier 432da44dc4 Bump! 2026-07-09 20:33:03 +02:00
binwiederhier 703d7bb9de Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-09 20:15:14 +02:00
LwaziProjects 98a0daba86 Added translation using Weblate (Zulu) 2026-07-09 11:27:03 +02:00
binwiederhier ce01b357d8 Bump prettier 2026-07-08 23:21:23 +02:00
binwiederhier c40a2ce0a7 Update android release notes 2026-07-08 23:07:41 +02:00
binwiederhier 88e598d8b8 RElease notes 2026-07-08 22:44:57 +02:00
binwiederhier 6869d166ae fmt 2026-07-08 22:42:42 +02:00
binwiederhier 08d81a3645 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-07-08 22:23:40 +02:00
Philipp C. Heckel 202d858826 Merge pull request #1814 from binwiederhier/dependabot/npm_and_yarn/web/i18next-browser-languagedetector-8.2.1
Bump i18next-browser-languagedetector from 6.1.8 to 8.2.1 in /web
2026-07-08 22:23:27 +02:00
Philipp C. Heckel 73e9d46b49 Merge pull request #1822 from binwiederhier/dependabot/go_modules/golang.org/x/text-0.39.0
Bump golang.org/x/text from 0.38.0 to 0.39.0
2026-07-08 22:23:10 +02:00
binwiederhier 9a021aba2d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-08 22:15:47 +02:00
Philipp C. Heckel 812dc4cded Merge pull request #1826 from binwiederhier/template-execute-context
Template execution rework
2026-07-08 22:15:37 +02:00
Philipp C. Heckel 9f6c4743b3 Merge pull request #1817 from binwiederhier/dependabot/github_actions/all-9e904a7c11
Bump the all group across 1 directory with 2 updates
2026-07-08 22:14:47 +02:00
binwiederhier 165f012ae6 Add AST check to catch calls, remove regex for block and template 2026-07-08 21:56:57 +02:00
binwiederhier 1d69ebaf58 Additional test 2026-07-08 21:18:19 +02:00
binwiederhier a001ac5195 Template execution rework 2026-07-08 21:08:07 +02:00
109247019824 bde864756e Translated using Weblate (Bulgarian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-07-08 00:01:19 +02:00
dependabot[bot] b6d21415bb Bump the all group across 1 directory with 2 updates
Bumps the all group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-go](https://github.com/actions/setup-go).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...924ae3a1cded613372ab5595356fb5720e22ba16)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 21:44:06 +00:00
dependabot[bot] 2e3d5babc8 Bump golang.org/x/text from 0.38.0 to 0.39.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.38.0 to 0.39.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.38.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 21:42:37 +00:00
NooB9496 b16efd2cb6 Translated using Weblate (Polish)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pl/
2026-07-06 07:01:20 +02:00
Antonio 24f991c6d7 Translated using Weblate (Spanish)
Currently translated at 92.1% (423 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2026-07-06 07:01:19 +02:00
binwiederhier 7b54850c16 Make twilioClient 2026-07-03 21:04:06 -04:00
binwiederhier be3dbdcb48 Move action parsing to action package 2026-07-03 20:54:45 -04:00
binwiederhier 2eabfd2f01 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-03 18:32:40 -04:00
binwiederhier d0054ea394 Split out auth to server_auth.go 2026-07-03 18:32:25 -04:00
binwiederhier 97282d6e3d Release notes 2026-07-02 20:54:30 -04:00
NooB9496 ef7aa31881 Translated using Weblate (Polish)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pl/
2026-07-03 02:53:33 +02:00
Shoshin Akamine aaa124973b Translated using Weblate (Japanese)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2026-07-03 02:53:32 +02:00
binwiederhier 69c2c18a9e Moved template functions 2026-07-02 17:17:39 -04:00
binwiederhier 479e406493 Split out web functions to server_web.go 2026-07-02 17:11:24 -04:00
binwiederhier 6429d36708 Bump 2026-07-02 16:57:41 -04:00
binwiederhier 73f771dafa Release notes 2026-07-02 16:53:27 -04:00
binwiederhier 5e1f27d709 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-07-02 16:51:06 -04:00
Philipp C. Heckel 6ed71dd4d0 Merge pull request #1818 from jvoisin/cleanup
Remove a useless loop
2026-07-02 16:46:19 -04:00
binwiederhier b4adf85805 Remove MagicBell as sponsor 2026-07-02 14:24:56 -04:00
jvoisin 7c0ab6e6b0 Remove a useless loop
It's functionally equivalent to the lines above it.
2026-07-02 18:13:06 +02:00
binwiederhier e39de727e0 Bump and Fixes 2026-07-01 11:49:48 -04:00
binwiederhier 8a67b5129e Strip unsafe URLs from Markdown links 2026-07-01 10:28:33 -04:00
Priit Jõerüüt f5067d295c Translated using Weblate (Estonian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/
2026-07-01 12:01:25 +02:00
Gringo 958ce520ec Translated using Weblate (Italian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/it/
2026-07-01 12:01:22 +02:00
Guillaume Petit edbf600cd7 Translated using Weblate (French)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2026-07-01 12:01:19 +02:00
dependabot[bot] 6ed57ec064 Bump i18next-browser-languagedetector from 6.1.8 to 8.2.1 in /web
Bumps [i18next-browser-languagedetector](https://github.com/i18next/i18next-browser-languageDetector) from 6.1.8 to 8.2.1.
- [Changelog](https://github.com/i18next/i18next-browser-languageDetector/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-browser-languageDetector/compare/v6.1.8...v8.2.1)

---
updated-dependencies:
- dependency-name: i18next-browser-languagedetector
  dependency-version: 8.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 21:42:51 +00:00
Priit Jõerüüt e28d8aca59 Translated using Weblate (Estonian)
Currently translated at 96.5% (443 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/
2026-06-30 11:30:24 +02:00
internetezoo a6f0447482 Translated using Weblate (Hungarian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/hu/
2026-06-30 11:30:21 +02:00
binwiederhier f2b22c114f Fix lint 2026-06-29 22:32:13 -04:00
binwiederhier c873064cad Date / time format 2026-06-29 22:24:04 -04:00
cyberboh 4c0bd70408 Translated using Weblate (Indonesian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2026-06-28 13:00:31 +02:00
Philipp C. Heckel 9078662bdd Merge pull request #1806 from sifio-dev/add-sifio-integration
Add Sifio to the Integrations page
2026-06-27 13:44:53 -04:00
sifio-dev 5970f03973 Add Sifio to the Integrations page 2026-06-28 01:38:05 +08:00
binwiederhier 6cbcfd95fa Helm install instructions 2026-06-27 09:52:45 -04:00
binwiederhier 6b1339ff4a Bump release note 2026-06-27 09:37:30 -04:00
binwiederhier 7881b973d7 Bump Go 2026-06-27 09:10:23 -04:00
binwiederhier 3a5458d237 Bump develop docs 2026-06-27 09:06:36 -04:00
binwiederhier f6ab9e7c58 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-27 08:56:54 -04:00
Hosted Weblate user 54392 a8ac283353 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-27 06:01:25 +02:00
weiwudi 86b077a292 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-27 06:01:24 +02:00
109247019824 d44a566528 Translated using Weblate (Bulgarian)
Currently translated at 90.6% (416 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-06-27 06:01:21 +02:00
Philipp Heckel 72b7155130 Translated using Weblate (German)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2026-06-27 06:01:19 +02:00
Philipp C. Heckel aeacd0a3d6 Merge pull request #1804 from jacob-masse/add-flowtriq-integration
Add Flowtriq DDoS detection to integrations
2026-06-26 16:03:12 -04:00
Philipp C. Heckel 3302ad2479 Merge pull request #1802 from binwiederhier/transitions
Transitions
2026-06-26 16:01:45 -04:00
binwiederhier c9105dad09 Release notes 2026-06-26 16:01:18 -04:00
binwiederhier 5765be7892 Root ref 2026-06-26 15:59:34 -04:00
binwiederhier 7a99bfc717 COndense comments, better prefcache use for theme 2026-06-26 07:58:28 -04:00
binwiederhier 156ad4ae92 Merge branch 'main' into transitions 2026-06-26 06:21:21 -04:00
binwiederhier a6dc691672 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-26 06:21:15 -04:00
binwiederhier 9114d1e996 Pref context 2026-06-26 06:20:58 -04:00
Rui Barbosa 55d871aeb2 Translated using Weblate (Portuguese)
Currently translated at 94.3% (433 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-06-26 05:16:49 +02:00
Hosted Weblate user 54392 a5318a4312 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-26 05:16:49 +02:00
109247019824 f77d299aa3 Translated using Weblate (Bulgarian)
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-06-26 05:16:49 +02:00
binwiederhier af66cc3ddc Undo theme weirdness 2026-06-25 22:40:34 -04:00
binwiederhier a47eef5006 Centralize fade out 2026-06-25 22:33:25 -04:00
binwiederhier 0ac7cbd4fe Merge branch 'main' of github.com:binwiederhier/ntfy into transitions 2026-06-25 21:52:58 -04:00
binwiederhier 72ac95148b Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-25 21:52:31 -04:00
Rui Barbosa 817a0ece7e Translated using Weblate (Portuguese)
Currently translated at 94.3% (433 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-06-26 03:52:11 +02:00
Hosted Weblate user 54392 e617c5f453 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-06-26 03:52:09 +02:00
109247019824 a04a12eb5f Translated using Weblate (Bulgarian)
Currently translated at 88.2% (405 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-06-26 03:52:09 +02:00
binwiederhier 46799db753 German translations 2026-06-25 21:44:34 -04:00
binwiederhier 5e32f05302 Read /account from primary 2026-06-25 20:24:50 -04:00
Jacob ad8391dd31 Add Flowtriq DDoS detection to integrations
Add Flowtriq to the HTTP integrations list and include a usage example
showing how to configure ftagent webhook alerts to POST to an ntfy topic
for real-time DDoS attack notifications.
2026-06-25 15:25:09 -04:00
binwiederhier e3889746c4 Merge branch 'main' of github.com:binwiederhier/ntfy into transitions 2026-06-24 20:17:48 -04:00
binwiederhier df6f1f3ee1 Bump 2026-06-24 19:16:09 -04:00
binwiederhier 047a1258f1 Transitions 2026-06-24 17:08:32 -04:00
binwiederhier 2b1f2d6b9a WIP: Web app transitions 2026-06-24 15:27:40 -04:00
binwiederhier 87e9dc9da4 Back link 2026-06-24 12:21:41 -04:00
binwiederhier 5d96888f0e Release notes 2026-06-24 12:15:11 -04:00
binwiederhier 65e409e9e4 PWA reload icon, lazy loading: EmojiPicker and MarkdownContent 2026-06-24 11:44:38 -04:00
binwiederhier 8022401728 Fix import loop in web app 2026-06-24 07:17:34 -04:00
binwiederhier 6d86574a38 Update release notes 2026-06-24 06:53:54 -04:00
binwiederhier 6a4f68897a Fix topic reservation icon in nav bar 2026-06-24 06:48:51 -04:00
binwiederhier b0411e5c92 More web tests 2026-06-24 05:56:48 -04:00
binwiederhier c5e5be0746 Merge branch 'main' of github.com:binwiederhier/ntfy into 1511-ampersand 2026-06-24 05:42:12 -04:00
binwiederhier ab2966e70f Add basic JS tests 2026-06-24 05:18:22 -04:00
binwiederhier 3a960b1b89 Release notes 2026-06-24 04:32:08 -04:00
Philipp C. Heckel ad45f70fd1 Merge pull request #1772 from mitya12342/http-sound-playback
Play notification sounds from the page ignoring Notification API support
2026-06-24 04:28:12 -04:00
binwiederhier 4b070546b5 Dependency bumps 2026-06-24 04:17:59 -04:00
binwiederhier e6a201bc11 Fix passwords with spaces 2026-06-23 13:10:17 -04:00
binwiederhier 8327047b71 Merge branch '1771-delete-clear-via-get' 2026-06-23 11:52:27 -04:00
binwiederhier c44575d7a1 Typo 2026-06-23 11:52:18 -04:00
binwiederhier f121f2ba8d Merge branch 'main' of github.com:binwiederhier/ntfy into 1771-delete-clear-via-get 2026-06-23 11:51:39 -04:00
binwiederhier 0c313906ef Release notes 2026-06-23 11:34:41 -04:00
binwiederhier ab1e170a20 Fix S3 scheme backwards compat 2026-06-22 21:43:29 -04:00
binwiederhier fb75a65885 Merge remote-tracking branch 'origin/main' into s3-scheme 2026-06-22 21:41:41 -04:00
binwiederhier 74240328dc Changelog, constant time compare 2026-06-22 21:21:48 -04:00
Philipp C. Heckel 3bfb9f334b Merge pull request #1795 from lmorchard/fix/sync-topic-deny-all
Grant users access to their own sync topic under deny-all (fixes #733)
2026-06-22 21:17:37 -04:00
binwiederhier 7b37f2a3eb Bump 2026-06-22 21:10:24 -04:00
binwiederhier d5b13a925e Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-22 21:08:32 -04:00
Philipp C. Heckel 50ac2c1925 Merge pull request #1785 from binwiederhier/password-reset
Password reset
2026-06-22 21:08:16 -04:00
binwiederhier 2b30ce9ee0 Changelog update 2026-06-22 21:07:55 -04:00
binwiederhier 9b3ab5a302 Remove migration test 2026-06-22 17:02:47 -04:00
binwiederhier 4313b02fc6 Allow primary email for provisioend users 2026-06-22 12:59:50 -04:00
binwiederhier d8666b66ec Change "Email: yes" behavior to make more sense 2026-06-22 10:13:27 -04:00
binwiederhier 5808c4d4c0 Rename variable 2026-06-21 11:53:25 -04:00
binwiederhier 954fae44dc Make more readable 2026-06-21 11:47:14 -04:00
binwiederhier 1979dbc7c3 Rename 2026-06-21 11:27:02 -04:00
binwiederhier d7dea6d250 Review 2026-06-21 11:19:49 -04:00
binwiederhier eab3988304 Words 2026-06-21 10:01:07 -04:00
binwiederhier 7494d0acf6 fmt 2026-06-21 09:53:39 -04:00
binwiederhier 914bf3b0c4 Manual refinement 2026-06-21 09:52:39 -04:00
binwiederhier c45744558b , 2026-06-20 15:27:14 -04:00
binwiederhier 74332fa302 Remove unused strings 2026-06-20 15:18:58 -04:00
binwiederhier 3d02c99394 Strings 2026-06-20 15:11:54 -04:00
binwiederhier 36d7d3bd24 Rename 2026-06-20 14:55:27 -04:00
binwiederhier 8a7b73cc7e Remove dead strings 2026-06-20 14:20:36 -04:00
binwiederhier bb2ca0facf Send "X-Email: yes" to primary 2026-06-20 14:15:12 -04:00
binwiederhier 2ee8717e0c Don't use consts in queries 2026-06-20 13:58:23 -04:00
Les OrchardandClaude Opus 4.8 cac3b2986a Grant users access to their own sync topic under deny-all
Manager.Authorize had no special case for a user's own sync topic, so with
auth-default-access=deny-all the per-account sync topic (st_...) fell through
to the default access and was denied. This broke web app account sync for
non-admin users: wss://.../st_<id>/ws returned 403. Admin-role users were
unaffected via the existing role bypass.

Allow a user full access to their own SyncTopic in Authorize. This fixes
existing users without a migration, since it needs no ACL row.

Fixes #733

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:58:29 -07:00
Sven Skender 19af0b65cc Preserve the original S3 endpoint scheme 2026-06-17 15:45:08 +02:00
binwiederhier 01eabb288a Review 2026-06-16 21:35:03 -04:00
binwiederhier 99bc803271 Code review 2026-06-16 21:18:58 -04:00
binwiederhier d8c87d04e7 Update privacy policy, code review 2026-06-16 20:56:22 -04:00
binwiederhier b75d0e582c Rename 2026-06-15 22:33:30 -04:00
binwiederhier 44d5bcf875 Fix mail mess 2026-06-15 22:21:22 -04:00
binwiederhier fc59339f86 Rename 2026-06-15 21:47:09 -04:00
cyvnrs 5834f667b2 Added translation using Weblate (Telugu) 2026-06-15 11:25:58 +02:00
binwiederhier 9fa8550ef6 Remove Close() on Sender; words on password dialog 2026-06-13 13:42:32 -04:00
binwiederhier eff808d0f8 Lint 2026-06-13 09:35:09 -04:00
binwiederhier ca58b885cb Make chips look better 2026-06-12 23:22:37 -04:00
binwiederhier 1215e99098 Provide email during signup 2026-06-12 23:13:01 -04:00
binwiederhier f558935c1e Re-wording, chips 2026-06-12 22:33:09 -04:00
binwiederhier 4516adea36 Lots of refinement 2026-06-12 17:36:40 -04:00
binwiederhier 33ae31055c Phase 3+4 2026-06-12 14:23:32 -04:00
binwiederhier 30dd4840a2 Phase 2, email verification rework, ui stuff 2026-06-12 11:40:59 -04:00
Philipp C. Heckel fd0f0657b9 Merge pull request #1784 from rubixvi/patch-2
chores(docs): addition of WordPress ntfy integration
2026-06-12 11:24:38 -04:00
binwiederhier 44dac47d76 Phsae 2 fix tesPhsae 2 fix testt 2026-06-12 11:12:26 -04:00
binwiederhier fd716e4807 Phase 1 2026-06-12 11:07:48 -04:00
Vincent Vu a0775701c1 chores(docs): addition of WordPress ntfy integration
Add WordPress Rubix Notify - ntfy integration plugin.
2026-06-11 12:33:33 +10:00
Philipp C. Heckel d824f1a11a Merge pull request #1781 from binwiederhier/dependabot/github_actions/all-6a98abd9ac
Bump actions/checkout from 6.0.2 to 6.0.3 in the all group
2026-06-10 20:06:39 -04:00
dependabot[bot] 2d7faac0ea Bump actions/checkout from 6.0.2 to 6.0.3 in the all group
Bumps the all group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 21:44:37 +00:00
binwiederhier d1696ac5b6 Docs: add topic generator 2026-06-08 21:52:54 -04:00
Hunter Kehoe 737163ba59 update release notes 2026-06-06 10:42:25 -06:00
Hunter Kehoe 26bc28ae24 support clear|read via GET 2026-06-06 10:38:09 -06:00
Hunter Kehoe 88fff8264b support delete via GET 2026-06-06 10:37:46 -06:00
Dimitriy Goloborodko 9cc6124f18 Play notification sounds from the page ignoring Notification API support 2026-06-06 20:06:02 +10:00
binwiederhier 5ff460f1dc Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-05 13:18:54 -04:00
internetezoo 9d3019004e Translated using Weblate (Hungarian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/hu/
2026-06-01 16:01:07 +02:00
Peter Dave Hello dbcc89ed8b Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/
2026-05-28 17:11:36 +02:00
kap1bala 46a5338a30 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-05-19 04:11:32 +00:00
Hunter Kehoe fb89b87efb do not escape JSON response 2025-12-07 14:15:36 -07:00
164 changed files with 13116 additions and 2993 deletions
+3 -3
View File
@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '1.26.x'
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-
name: Checkout docs pages code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io
+3 -3
View File
@@ -25,11 +25,11 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '1.26.x'
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+3 -3
View File
@@ -25,11 +25,11 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '1.26.x'
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
+1
View File
@@ -0,0 +1 @@
1.26.5
+86 -14
View File
@@ -5,7 +5,9 @@ PIP := pip3
VERSION := $(shell git describe --tag)
COMMIT := $(shell git rev-parse --short HEAD)
.PHONY:
# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite
# forces that target's recipe to run every time (the classic "FORCE target" idiom).
FORCE:
help:
@echo "Typical commands (more see below):"
@@ -43,6 +45,7 @@ help:
@echo " make web-deps - Install web app dependencies (npm install the universe)"
@echo " make web-build - Actually build the web app"
@echo " make web-lint - Run eslint on the web app"
@echo " make web-test - Run vitest unit tests for the web app"
@echo " make web-fmt - Run prettier on the web app"
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
@echo
@@ -52,7 +55,9 @@ help:
@echo " make docs-build - Actually build the documentation"
@echo
@echo "Test/check:"
@echo " make test - Run tests"
@echo " make test - Run all tests (Go + web)"
@echo " make cli-test - Run Go tests only"
@echo " make web-test - Run web app tests only"
@echo " make race - Run tests with -race flag"
@echo " make coverage - Run tests and show coverage"
@echo " make coverage-html - Run tests and show coverage (as HTML)"
@@ -82,12 +87,12 @@ help:
# Building everything
clean: .PHONY
clean: FORCE
rm -rf dist build server/docs server/site
build: web docs cli
update: web-deps-update cli-deps-update docs-deps-update
update: web-deps-update cli-deps-update docs-deps-update go-check
docker pull alpine
docker-dev:
@@ -119,7 +124,7 @@ build-deps-ubuntu:
docs: docs-deps docs-build
docs-venv: .PHONY
docs-venv: FORCE
$(PYTHON) -m venv ./venv
docs-build: docs-venv
@@ -128,7 +133,7 @@ docs-build: docs-venv
docs-deps: docs-venv
(. venv/bin/activate && $(PIP) install -r requirements.txt)
docs-deps-update: .PHONY
docs-deps-update: FORCE
(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
@@ -163,6 +168,9 @@ web-fmt-check:
web-lint:
cd web && $(NPM) run lint
web-test:
cd web && $(NPM) run test
# Main server/client build
cli: cli-deps
@@ -265,17 +273,21 @@ cli-build-results:
# Test/check targets
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
check: test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
test: .PHONY
test: cli-test web-test
testv: cli-testv web-test
cli-test: FORCE
go test $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
testv: .PHONY
cli-testv: FORCE
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
race: .PHONY
race: FORCE
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
coverage:
@@ -305,17 +317,73 @@ vet:
lint:
which golint || go install golang.org/x/lint/golint@latest
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
go list ./... | grep -v /vendor/ | grep -vE 'ntfy/v2/template/gotext' | xargs -L1 golint -set_exit_status
staticcheck: .PHONY
staticcheck: FORCE
rm -rf build/staticcheck
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
mkdir -p build/staticcheck
ln -s "go" build/staticcheck/go
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck $$(go list ./... | grep -vE 'ntfy/v2/template/gotext')
rm -rf build/staticcheck
# Vendored template targets (see template/README.md)
TEMPLATE_GO_VERSION := go$(shell cat .go-version 2>/dev/null)
update-template:
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo "ERROR: local Go $$(go env GOVERSION) != $(TEMPLATE_GO_VERSION) pinned in .go-version."; \
echo "Bump .go-version and install that toolchain first: go install golang.org/dl/$(TEMPLATE_GO_VERSION)@latest && $(TEMPLATE_GO_VERSION) download"; \
exit 1; \
fi
src="$$(go env GOROOT)/src"; \
rm -f template/gotext/*.go template/gotext/fmtsort/*.go; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" template/gotext/; done; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" template/gotext/fmtsort/; done; \
sed -i 's/^package template$$/package gotext/' template/gotext/*.go; \
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' template/gotext/*.go; \
( cd template/gotext && for p in patches/*.patch; do echo "Applying $$p"; git apply "$$p" || exit 1; done )
go env GOVERSION > template/gotext/GENERATED_FROM
@echo "Regenerated template/gotext/ from $(TEMPLATE_GO_VERSION) (files enumerated via 'go list'); review with 'git diff'."
template-check: FORCE
@if [ "$$(cat template/gotext/GENERATED_FROM)" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo "ERROR: template/gotext was generated from $$(cat template/gotext/GENERATED_FROM), but .go-version pins $(TEMPLATE_GO_VERSION). Run 'make update-template' on the pinned Go."; \
exit 1; \
fi
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo "SKIP: local Go $$(go env GOVERSION) != pinned $(TEMPLATE_GO_VERSION); skipping vendored template content check (version marker already verified)."; \
exit 0; \
fi
@tmp=$$(mktemp -d); src="$$(go env GOROOT)/src"; \
mkdir -p "$$tmp/gotext/fmtsort"; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" "$$tmp/gotext/"; done; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" "$$tmp/gotext/fmtsort/"; done; \
sed -i 's/^package template$$/package gotext/' "$$tmp/gotext/"*.go; \
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' "$$tmp/gotext/"*.go; \
cp template/gotext/patches/*.patch "$$tmp/"; \
( cd "$$tmp/gotext" && for p in "$$tmp"/*.patch; do git apply "$$p" || exit 1; done ); \
if diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext >/dev/null 2>&1; then \
rm -rf "$$tmp"; \
else \
echo "ERROR: template/gotext/ drifted from GOROOT+patches (or its file set changed). Run 'make update-template' on Go $(TEMPLATE_GO_VERSION):"; \
diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext; \
rm -rf "$$tmp"; exit 1; \
fi
# go-check is advisory only (never fails): it warns when the pinned Go (.go-version) is behind the
# latest upstream release, so template/gotext doesn't silently fall behind on text/template fixes.
go-check: FORCE
@latest=$$(curl -s --max-time 10 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); \
if [ -n "$$latest" ] && [ "$$latest" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo ""; \
echo "note: latest Go is $$latest, but template/gotext is pinned to $(TEMPLATE_GO_VERSION) (.go-version)."; \
echo " to bump: install $$latest, set .go-version to $${latest#go}, then run 'make update-template'."; \
fi
# Releasing targets
release: clean cli-deps release-checks docs web check
@@ -326,6 +394,10 @@ release-snapshot: clean cli-deps docs web check
release-checks:
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
if [ "$$(go env GOVERSION)" != "go$$(cat .go-version)" ]; then\
echo "ERROR: releases must use the pinned Go toolchain (go$$(cat .go-version) from .go-version), but this is $$(go env GOVERSION). This also ensures 'make check' enforces (not skips) the template/gotext drift check.";\
exit 1;\
fi
if ! grep -q $(LATEST_TAG) docs/install.md; then\
echo "ERROR: Must update docs/install.md with latest tag first.";\
exit 1;\
+1 -2
View File
@@ -84,8 +84,6 @@ Thank you to our commercial sponsors, who help keep the service running and the
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></a>
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
@@ -268,6 +266,7 @@ Third-party libraries and resources:
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
* Go's [text/template](https://pkg.go.dev/text/template) (BSD-3-Clause) is vendored under [template/gotext/](template/gotext/) with a small patch adding an execution deadline (see [template/gotext/README.md](template/gotext/README.md))
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
+5 -3
View File
@@ -1,4 +1,6 @@
package server
// Package action parses the "action buttons" that can be attached to a notification, in both the
// JSON and the human-readable "simple" format described at https://ntfy.sh/docs/publish/#action-buttons.
package action
import (
"encoding/json"
@@ -37,10 +39,10 @@ type actionParser struct {
pos int
}
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
// Parse parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
func parseActions(s string) (actions []*model.Action, err error) {
func Parse(s string) (actions []*model.Action, err error) {
// Parse JSON or simple format
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[") {
@@ -1,4 +1,4 @@
package server
package action
import (
"testing"
@@ -7,12 +7,12 @@ import (
)
func TestParseActions(t *testing.T) {
actions, err := parseActions("[]")
actions, err := Parse("[]")
require.Nil(t, err)
require.Empty(t, actions)
// Basic test
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -23,7 +23,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "https://door.lan", actions[1].URL)
// JSON
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
actions, err = Parse(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -34,7 +34,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "https://door.lan", actions[1].URL)
// Other params
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -44,7 +44,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "this is a body", actions[0].Body)
// Extras with underscores
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
actions, err = Parse("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
@@ -54,7 +54,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
// Broadcast action with intent
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
actions, err = Parse("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
@@ -62,7 +62,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
// Headers with dashes
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
actions, err = Parse("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -72,7 +72,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
// Quotes
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
actions, err = Parse(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -80,7 +80,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://example.com`, actions[0].URL)
// Single quotes
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
actions, err = Parse(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -88,7 +88,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://example.com`, actions[0].URL)
// Single quotes (JSON)
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
actions, err = Parse(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -97,7 +97,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
// Out of order
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
actions, err = Parse(`label="Out of order!" , action="http", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -105,7 +105,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://example.com`, actions[0].URL)
// Spaces
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
actions, err = Parse(`action = http, label = 'this is a label', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -113,7 +113,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://google.com`, actions[0].URL)
// Non-ASCII
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
actions, err = Parse(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -121,7 +121,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://google.com`, actions[0].URL)
// Multiple actions, awkward spacing
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
actions, err = Parse(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -134,7 +134,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, true, actions[1].Clear)
// Copy action (simple format)
actions, err = parseActions("copy, Copy code, 1234")
actions, err = Parse("copy, Copy code, 1234")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -142,7 +142,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "1234", actions[0].Value)
// Copy action (JSON)
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
actions, err = Parse(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -150,7 +150,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "567890", actions[0].Value)
// Copy action with clear
actions, err = parseActions("copy, Copy code, 1234, clear=true")
actions, err = Parse("copy, Copy code, 1234, clear=true")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -159,7 +159,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, true, actions[0].Clear)
// Copy action with explicit value key
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
actions, err = Parse("action=copy, label=Copy token, clear=true, value=abc-123-def")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -168,56 +168,56 @@ func TestParseActions(t *testing.T) {
require.True(t, actions[0].Clear)
// Copy action without value (error)
_, err = parseActions("copy, Copy code")
_, err = Parse("copy, Copy code")
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
// Invalid syntax
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
_, err = Parse(`label="Out of order!" x, action="http", url=http://example.com`)
require.EqualError(t, err, "unexpected character 'x' at position 22")
_, err = parseActions(`label="", action="http", url=http://example.com`)
_, err = Parse(`label="", action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
_, err = parseActions(`label=, action="http", url=http://example.com`)
_, err = Parse(`label=, action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
_, err = Parse(`label="xx", action="http", url=http://example.com, what is this anyway`)
require.EqualError(t, err, "term 'what is this anyway' unknown")
_, err = parseActions(`fdsfdsf`)
_, err = Parse(`fdsfdsf`)
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
_, err = Parse(`aaa=a, "bbb, 'ccc, ddd, eee "`)
require.EqualError(t, err, "key 'aaa' unknown")
_, err = parseActions(`action=http, label="omg the end quote is missing`)
_, err = Parse(`action=http, label="omg the end quote is missing`)
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
_, err = parseActions(`;;;;`)
_, err = Parse(`;;;;`)
require.EqualError(t, err, "only 3 actions allowed")
_, err = parseActions(`,,,,,,;;`)
_, err = Parse(`,,,,,,;;`)
require.EqualError(t, err, "term '' unknown")
_, err = parseActions(`''";,;"`)
_, err = Parse(`''";,;"`)
require.EqualError(t, err, "unexpected character '\"' at position 2")
_, err = parseActions(`action=http, label=a label, body=somebody`)
_, err = Parse(`action=http, label=a label, body=somebody`)
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
_, err = Parse(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
_, err = parseActions(`[ invalid json ]`)
_, err = Parse(`[ invalid json ]`)
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
_, err = parseActions(`[ { "some": "object" } ]`)
_, err = Parse(`[ { "some": "object" } ]`)
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions("\x00\x01\xFFx\xFE")
_, err = Parse("\x00\x01\xFFx\xFE")
require.EqualError(t, err, "invalid utf-8 string")
_, err = parseActions(`http, label, http://x.org, clear=x`)
_, err = Parse(`http, label, http://x.org, clear=x`)
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+88 -3
View File
@@ -8,11 +8,13 @@ import (
"fmt"
"os"
"strings"
"time"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
@@ -32,12 +34,17 @@ var flagsUser = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
)
var cmdUser = &cli.Command{
Name: "user",
Usage: "Manage/show users",
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...",
Flags: flagsUser,
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
Category: categoryServer,
@@ -98,6 +105,30 @@ Example:
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
directly the bcrypt hash. This is useful if you are updating users via scripts.
`,
},
{
Name: "reset-pass",
Aliases: []string{"rp"},
Usage: "Generates a password reset link for a user",
UsageText: "ntfy user reset-pass [--send-email] USERNAME",
Action: execUserResetPass,
Flags: []cli.Flag{
&cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"},
},
Description: `Generate a password reset link for the given user and print it to stdout.
The user completes the reset by opening the link in a browser and choosing a new password;
the admin never learns or chooses the new password. The link is single-use and expires after
one hour. This is an admin override of the self-service reset flow -- unlike self-service, it
does not require the user to have a verified primary email (the token is bound to the user).
With --send-email, the link is additionally emailed to the user's primary email address (this
requires SMTP to be configured and the user to have a verified primary email).
Example:
ntfy user reset-pass phil # Print a reset link for user phil
ntfy user reset-pass --send-email phil # Print and email the reset link
`,
},
{
@@ -257,7 +288,6 @@ func execUserDel(c *cli.Context) error {
func execUserChangePass(c *cli.Context) error {
username := c.Args().Get(0)
password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH")
if !hashed {
password = os.Getenv("NTFY_PASSWORD")
}
@@ -286,6 +316,61 @@ func execUserChangePass(c *cli.Context) error {
return nil
}
func execUserResetPass(c *cli.Context) error {
username := c.Args().Get(0)
sendEmail := c.Bool("send-email")
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
if username == "" {
return errors.New("username expected, type 'ntfy user reset-pass --help' for help")
} else if username == userEveryone || username == user.Everyone {
return errors.New("username not allowed")
} else if baseURL == "" {
return errors.New("base-url must be configured to generate a reset link")
}
manager, err := createUserManager(c)
if err != nil {
return err
}
u, err := manager.User(username)
if errors.Is(err, user.ErrUserNotFound) {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
} else if u.Provisioned {
return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username)
}
// Resolve the primary email up front if we need to send -- fail before creating a token
var primaryEmail string
if sendEmail {
primaryEmail, err = manager.PrimaryEmail(u.ID)
if err != nil {
return err
} else if primaryEmail == "" {
return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username)
}
}
// The reset token is bound to the user, not an email -- so this works even with no SMTP
token, err := manager.AddMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour)
if err != nil {
return err
}
link := baseURL + "/account/password/reset/" + token
fmt.Fprintln(c.App.Writer, link)
if sendEmail {
sender := mail.NewSender(&mail.Config{
SMTPAddr: c.String("smtp-sender-addr"),
SMTPUser: c.String("smtp-sender-user"),
SMTPPass: c.String("smtp-sender-pass"),
From: c.String("smtp-sender-from"),
})
if err := sender.SendPasswordReset(primaryEmail, link); err != nil {
return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err)
}
fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail)
}
return nil
}
func execUserChangeRole(c *cli.Context) error {
username := c.Args().Get(0)
role := user.Role(c.Args().Get(1))
@@ -313,7 +398,7 @@ func execUserHash(c *cli.Context) error {
if err != nil {
return err
}
hash, err := user.HashPassword(password)
hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
+63
View File
@@ -122,6 +122,69 @@ func TestCLI_User_Delete(t *testing.T) {
require.Contains(t, err.Error(), "user phil does not exist")
}
func TestCLI_User_ResetPass(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, _ := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
// Prints a working-looking reset link when base-url is set
app, _, stdout, _ := newTestApp()
require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil"))
require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/")
}
func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, _ := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
app, _, _, _ = newTestApp()
err := runUserCommand(app, conf, "reset-pass", "phil")
require.Error(t, err)
require.Contains(t, err.Error(), "base-url")
}
func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, _ := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
// --send-email requires a primary email; phil has none
app, _, _, _ = newTestApp()
err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil")
require.Error(t, err)
require.Contains(t, err.Error(), "no primary email")
}
func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
// Seed a provisioned user into the auth database via config provisioning
m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{
ProvisionEnabled: true,
Users: []*user.User{
{Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
},
})
require.Nil(t, err)
require.Nil(t, m.Close())
app, _, _, _ := newTestApp()
err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser")
require.Error(t, err)
require.Contains(t, err.Error(), "provisioned")
}
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
+9
View File
@@ -90,6 +90,15 @@ func (d *DB) ReadOnly() *sql.DB {
return d.primary.DB
}
// MarkReplicasHealthyForTest immediately marks all configured replicas as healthy, bypassing the
// async health-check loop's initial delay. It exists so tests can deterministically route
// ReadOnly() to a replica without waiting; it is not used in production code.
func (d *DB) MarkReplicasHealthyForTest() {
for _, r := range d.replicas {
r.healthy.Store(true)
}
}
// Close closes the primary database and all replicas, and stops the health-check goroutine.
func (d *DB) Close() error {
d.cancel()
+6 -3
View File
@@ -1047,9 +1047,12 @@ configured for `ntfy.sh`):
```
By default, any user (including anonymous users) can send email notifications to any address. To require email
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails,
and authenticated users can only send to email addresses they have verified in their account settings. Users can
also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address.
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and
authenticated users can only send to *literal* email addresses they have verified in their account settings.
Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary
verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only
governs whether arbitrary literal addresses are allowed.
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
+6 -4
View File
@@ -65,8 +65,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
``` shell
wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz
wget https://go.dev/dl/go1.25.8.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.8.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
go version # verifies that it worked
```
@@ -77,9 +77,11 @@ go install github.com/goreleaser/goreleaser@latest
goreleaser -v # verifies that it worked
```
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)).
Use a current LTS release (Node 24 is what CI builds with; anything older than Node 20 will not work
with the current Vite-based web build):
``` shell
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
npm -v # verifies that it worked
```
+26
View File
@@ -641,6 +641,32 @@ or by simply providing traccar with a valid username/password combination.
<entry key='sms.http.password'>mypass</entry>
```
## Flowtriq DDoS detection
[Flowtriq](https://flowtriq.com) is a real-time DDoS detection and mitigation platform. Its Linux agent, ftagent,
supports webhook alerts that can POST directly to an ntfy topic, so you get push notifications on your phone
whenever an attack is detected.
Configure the webhook URL in your ftagent configuration to point to your ntfy topic:
```yaml
# /etc/ftagent/ftagent.yml
alerts:
webhooks:
- url: https://ntfy.sh/flowtriq-attacks
method: POST
```
You can also use curl to test the integration manually with a sample attack alert:
```bash
curl \
-H "Title: DDoS Attack Detected" \
-H "Priority: urgent" \
-H "Tags: rotating_light" \
-d "Attack detected on 203.0.113.5: 14.2 Gbps UDP flood from 3,482 sources" \
ntfy.sh/flowtriq-attacks
```
## Terminal Notifications for Long-Running Commands
This example provides a simple way to send notifications using [ntfy.sh](https://ntfy.sh) when a terminal command completes. It includes success or failure indicators based on the command's exit status.
+66 -38
View File
@@ -34,37 +34,37 @@ as a service starting at boot time.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.tar.gz
tar zxvf ntfy_2.24.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.24.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.tar.gz
tar zxvf ntfy_2.26.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.26.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.tar.gz
tar zxvf ntfy_2.24.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.24.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.tar.gz
tar zxvf ntfy_2.26.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.26.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.tar.gz
tar zxvf ntfy_2.24.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.24.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.tar.gz
tar zxvf ntfy_2.26.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.26.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.tar.gz
tar zxvf ntfy_2.24.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.24.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.24.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.tar.gz
tar zxvf ntfy_2.26.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.26.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
@@ -118,25 +118,25 @@ Install the ntfy server service script:
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.24.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
sudo mv ntfy_2.24.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.24.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
sudo mv ntfy_2.24.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
@@ -204,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -212,7 +212,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -220,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -228,7 +228,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -238,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -301,18 +301,18 @@ pkg install go-ntfy
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_darwin_all.tar.gz > ntfy_2.24.0_darwin_all.tar.gz
tar zxvf ntfy_2.24.0_darwin_all.tar.gz
sudo cp -a ntfy_2.24.0_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz > ntfy_2.26.0_darwin_all.tar.gz
tar zxvf ntfy_2.26.0_darwin_all.tar.gz
sudo cp -a ntfy_2.26.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.24.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.26.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -333,7 +333,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.24.0/ntfy_2.24.0_windows_amd64.zip),
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
@@ -726,3 +726,31 @@ kubectl apply -k /ntfy
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
```
## Helm
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
If you prefer [Helm](https://helm.sh/), ntfy can be deployed using the third-party
[HelmForge chart](https://helmforge.dev/docs/charts/ntfy), which packages the official
`binwiederhier/ntfy` image with persistent storage, Service, Ingress, optional Prometheus metrics, and more.
This chart is **not** maintained by the ntfy developers.
!!! warning
The HelmForge project is young and maintained by a small community. Review the chart before deploying it,
and use it at your own risk.
```bash
helm repo add helmforge https://repo.helmforge.dev
helm repo update
helm install ntfy helmforge/ntfy
```
Alternatively, install it directly from the OCI registry:
```bash
helm install ntfy oci://ghcr.io/helmforgedev/helm/ntfy
```
Because ntfy's default SQLite storage is single-writer, run the chart as a single instance rather than
treating it as a horizontally scalable deployment. See the [chart documentation](https://helmforge.dev/docs/charts/ntfy)
for the full list of configurable values.
+3
View File
@@ -43,6 +43,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform
- [Sifio](https://sifio.net) - Aggregate updates from RSS, social media, and other sources, then deliver them to ntfy, Slack, Notion and more
## Integration via HTTP/SMTP/etc.
@@ -54,6 +55,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.
- [Scrutiny](https://github.com/AnalogJ/scrutiny) - WebUI for smartd S.M.A.R.T monitoring. Scrutiny includes shoutrrr/ntfy integration ([see integration README](https://github.com/AnalogJ/scrutiny?tab=readme-ov-file#notifications))
- [UptimeObserver](https://uptimeobserver.com) - Uptime Monitoring tool for Websites, APIs, SSL Certificates, DNS, Domain Names and Ports. [Integration Guide](https://support.uptimeobserver.com/integrations/ntfy/)
- [Flowtriq](https://flowtriq.com) - Real-time DDoS detection and mitigation platform (integration via [webhook alerts](https://flowtriq.com))
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
@@ -189,6 +191,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services
- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React).
## Blog + forum posts
+8 -6
View File
@@ -1,6 +1,6 @@
# Privacy policy
**Last updated:** March 31, 2026
**Last updated:** June 15, 2026
This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information
when you use the ntfy.sh service, web app, and mobile applications (Android and iOS).
@@ -19,8 +19,9 @@ If you create an account on ntfy.sh, we collect:
- **Username** - A unique identifier you choose
- **Password** - Stored as a secure bcrypt hash (we never store your plaintext password)
- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified
email address for use with the email notification feature
- **Email address** - If you add an email address to your account for account recovery and password resets, for use
with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email
addresses you add to your account are verified by sending a confirmation link.
- **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call)
You can use ntfy without creating an account. Anonymous usage is fully supported.
@@ -77,9 +78,10 @@ Your phone number is shared with Twilio to deliver these services. Twilio's
### Amazon SES (email delivery)
If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to
deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure.
Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies.
If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email
address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The
recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's
[privacy policy](https://aws.amazon.com/privacy/) applies.
### Stripe (payments)
+91 -9
View File
@@ -1,7 +1,7 @@
# Publishing
Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)).
Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick
something that's not easily guessable.
something that's not easily guessable (see [picking a topic](#picking-a-topic) for a handy topic name generator).
Here's an example showing how to publish a simple message using a POST request:
@@ -308,6 +308,44 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
<figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>
</figure>
## Picking a topic
Since there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Topic names may
only contain letters, numbers, underscores and dashes (`[-_A-Za-z0-9]`), and may be up to 64 characters long.
Not sure what to pick? Type a name below and the generator will add a random, hard-to-guess suffix for you. Everything happens locally in your browser:
<div id="tg-widget" class="tg-generator">
<div class="tg-header">
<span class="tg-title">Topic name generator</span>
<button type="button" id="tg-reroll" class="tg-reset" title="Generate a new random suffix">Regenerate suffix</button>
</div>
<div class="tg-body">
<div class="tg-left">
<div class="tg-field">
<label for="tg-input">Type a topic name</label>
<input type="text" id="tg-input" placeholder="e.g. backups, alerts, phil-home" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
</div>
<div class="tg-note">Spaces and characters other than letters, numbers, <code>-</code> and <code>_</code> are removed automatically as you type. Names are capped at 64 characters.</div>
</div>
<div class="tg-right">
<div class="tg-output-row">
<span class="tg-output-label">Your topic:</span>
<div class="tg-output-line">
<pre class="tg-output" id="tg-output-name"></pre>
<button type="button" class="tg-btn-copy" data-copy="tg-output-name" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
</div>
</div>
<div class="tg-output-row">
<span class="tg-output-label">Your topic URL:</span>
<div class="tg-output-line">
<pre class="tg-output" id="tg-output-url">https://ntfy.sh/</pre>
<button type="button" class="tg-btn-copy" data-copy="tg-output-url" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
</div>
</div>
</div>
</div>
</div>
## Message title
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -2777,16 +2815,20 @@ Here's an example of a dead man's switch that sends an alert if the script stops
### Canceling scheduled notifications
You can cancel a scheduled message before it is delivered by sending a DELETE request to the
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). This will remove the
scheduled message from the server so it will never be delivered, and emit a `message_delete` event to any subscribers.
`/<topic>/<sequence_id>` endpoint, just like [deleting notifications](#deleting-notifications). Alternatively, you can send a `GET`
request to `/<topic>/<sequence_id>/delete`. This will remove the scheduled message from the server so it will never be delivered,
and emit a `message_delete` event to any subscribers.
=== "Command line (curl)"
```bash
# Schedule a reminder for 2 hours from now
curl -H "In: 2h" -d "Take a break!" ntfy.sh/mytopic/break-reminder
# Changed your mind? Cancel the scheduled message
# Changed your mind? Cancel the scheduled message via DELETE
curl -X DELETE ntfy.sh/mytopic/break-reminder
# Or cancel it via GET
curl ntfy.sh/mytopic/break-reminder/delete
```
=== "ntfy CLI"
@@ -3182,6 +3224,11 @@ You can use the following features in your templates:
A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
!!! info
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
a template that loops too long is stopped and rejected with an HTTP 400 error.
### Template functions
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
@@ -3213,8 +3260,13 @@ You can forward messages to e-mail by specifying an address in the header. This
you'd like to persist longer, or to blast-notify yourself on all possible channels.
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled),
you can also pass `yes`, `true`, or `1` to send to your first verified email address.
Only one e-mail address is supported.
If you are logged in and have a verified email address on your account, you can pass `yes`, `true`, or `1` instead of an
address to send to your **primary email address** (the one marked primary in the web app's
[Account section](https://ntfy.sh/account)); if you haven't designated a primary, it falls back to your first verified
address. This works regardless of the [`smtp-sender-verify`](config.md#e-mail-notifications) setting -- that setting only
controls whether *literal* addresses must already be verified on your account.
ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
@@ -3664,7 +3716,7 @@ all the supported fields:
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address |
| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary verified address |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
@@ -4100,26 +4152,41 @@ field the response. A sequence of updates may look like this (first example from
### Clearing notifications
Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
To do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
To do this, send a `PUT` request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
and dismiss the notification.
Alternatively, if your client has limited HTTP support, you can send a `GET` request to the same endpoints:
`GET /<topic>/<sequence_id>/clear` or `GET /<topic>/<sequence_id>/read`.
=== "Command line (curl)"
```bash
# Via PUT method
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
# Via GET method
curl ntfy.sh/mytopic/my-download-123/clear
```
=== "HTTP"
``` http
PUT /mytopic/my-download-123/clear HTTP/1.1
Host: ntfy.sh
# Or using GET
GET /mytopic/my-download-123/clear HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
// Via PUT method
await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
method: 'PUT'
});
// Via GET method
await fetch('https://ntfy.sh/mytopic/my-download-123/clear');
```
=== "Go"
@@ -4154,25 +4221,40 @@ An example response from the server with the `message_clear` event may look like
### Deleting notifications
Deleting a notification means **removing it from the notification drawer and from the client's database**.
To do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
To do this, send a `DELETE` request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
that is used by the clients (web app and Android app) to remove the notification entirely.
Alternatively, if your client has limited HTTP support (e.g. webhooks or IoT devices), you can also delete a message by sending
a `GET` request to `/<topic>/<sequence_id>/delete`.
=== "Command line (curl)"
```bash
# Via DELETE method
curl -X DELETE ntfy.sh/mytopic/my-download-123
# Via GET method
curl ntfy.sh/mytopic/my-download-123/delete
```
=== "HTTP"
``` http
DELETE /mytopic/my-download-123 HTTP/1.1
Host: ntfy.sh
# Or using GET
GET /mytopic/my-download-123/delete HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
// Via DELETE method
await fetch('https://ntfy.sh/mytopic/my-download-123', {
method: 'DELETE'
});
// Via GET method
await fetch('https://ntfy.sh/mytopic/my-download-123/delete');
```
=== "Go"
+74 -11
View File
@@ -4,15 +4,74 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Current stable releases
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.24.0 | June 4, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 |
| Component | Version | Release date |
|------------------|---------|---------------|
| ntfy server | v2.26.0 | July 9, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.24.0
### ntfy server v2.26.0
Released July 9, 2026
This release hardens **message templates**, which are now executed with a hard-capped execution timeout. This closes
a denial-of-service hole.
On the web app side, it adds configurable **date and time formats**, a smoother loading and page-transition experience,
and a fix that strips unsafe URL protocols from rendered Markdown.
**Security:**
* Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881) for reporting)
**Features:**
* Web app: Add "Date format" and "Time format" settings (Settings -> Appearance), with ISO 8601, day/month/year (slash or dot) and month/day/year date options and a 12-/24-hour clock option, and base the default format on your browser/system locale rather than the selected display language. When logged in, both settings sync across devices via your account ([#1647](https://github.com/binwiederhier/ntfy/issues/1647), thanks to [@wsw70](https://github.com/wsw70) for reporting)
**Bug fixes + maintenance:**
* Web app: Smooth transitions and loading animation, remove flickering
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
## ntfy server v2.25.0
Released June 24, 2026
This release adds **password reset** via email, and reworks email verification to use durable,
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
signup; a user can reset their password only once they have a verified "primary" (recovery)
email.
All of this work is probably not useful for self-hosters, but it hopefully will be useful for me,
since I do have to reset accounts on a regular basis.
**Security issues:**
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
**Features:**
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
* Add a reload button to the web app's action bar when running as an installed PWA, which clears the service worker caches and hard-refreshes the app ([#1281](https://github.com/binwiederhier/ntfy/issues/1281), thanks to [@leanza](https://github.com/leanza) for reporting)
* Add a "Back to app" link to the web app's login, signup, and password-reset pages (alongside the existing links), which previously had no way back to the app
**Bug fixes + maintenance:**
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
* Stop silently stripping spaces from passwords while typing in the web app's login, signup, and password-reset forms ([#1246](https://github.com/binwiederhier/ntfy/issues/1246), thanks to [@aldem](https://github.com/aldem) for reporting)
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
* Reduce the web app's initial bundle size by ~300 KB (~50 KB gzipped) by lazy-loading the emoji picker dataset and the Markdown renderer, and by importing Material UI icons individually
## ntfy server v2.24.0
Released June 4, 2026
The main feature for this release is an in-memory ACL cache (`auth-access-cache`) that can help bring down the read load
@@ -1948,13 +2007,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy Android v1.25.x (UNRELEASED)
### ntfy Android v1.25.2 (UNRELEASED)
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
is no network, ntfy will now stop the foreground service entirely.
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
once connectivity returns.
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
@@ -1964,16 +2024,19 @@ especially when paired with increaseing the server-side `keepalive-interval` in
**Features:**
* Add configurable "Alert when connection is lost" setting ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Suppress connection alerts and stop foreground service when there is no network ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution)
* Restart the foreground service immediately when network returns, even if the app process was killed while offline
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
**Bug fixes + maintenance:**
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
### ntfy iOS app v1.8.0 (UNRELEASED)
+235
View File
@@ -0,0 +1,235 @@
/* Topic name generator (Publishing page) */
/* Styled to mirror the config generator (header + left form / right output panels). */
.tg-generator {
margin: 16px 0 24px;
border: 1px solid #ddd;
border-radius: 10px;
background: #fff;
overflow: hidden;
font-size: 0.78rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
/* Header (matches .cg-modal-header) */
.tg-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid #ddd;
}
.tg-title {
font-weight: 600;
font-size: 0.9rem;
}
.tg-reset {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.72rem;
color: #777;
cursor: pointer;
padding: 4px 12px;
font-family: inherit;
transition: color 0.15s, border-color 0.15s;
}
.tg-reset:hover {
color: #333;
border-color: #999;
}
/* Body: left (form) + right (output), matches .cg-modal-body */
.tg-body {
display: flex;
min-height: 0;
}
.tg-left {
flex: 1;
border-right: 1px solid #ddd;
padding: 16px 18px;
min-width: 0;
}
.tg-right {
flex: 1;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
/* One output per block: label on its own line, then value field + copy button */
.tg-output-row {
display: flex;
flex-direction: column;
min-width: 0;
}
.tg-output-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* Form field (matches .cg-field) */
.tg-field > label {
display: block;
font-weight: 500;
margin-bottom: 4px;
font-size: 0.78rem;
color: #555;
}
.tg-field input[type="text"] {
width: 100%;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.78rem;
font-family: inherit;
box-sizing: border-box;
background: #fff;
}
.tg-field input[type="text"]:focus {
border-color: var(--md-primary-fg-color);
outline: none;
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
}
.tg-note {
margin-top: 10px;
font-size: 0.72rem;
color: #999;
line-height: 1.5;
}
.tg-note code {
font-size: 0.72rem;
padding: 1px 4px;
}
.tg-output-label {
margin-bottom: 4px;
white-space: nowrap;
font-weight: 500;
font-size: 0.78rem;
color: #555;
}
/* Copy button (matches .cg-btn-copy) */
.tg-btn-copy {
background: none;
color: #777;
border: none;
padding: 2px 4px;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
}
.tg-btn-copy:hover {
color: #333;
}
/* Output block (matches .cg-output-wrap pre). Scoped under .tg-generator so the margin
reset beats the theme's .md-typeset pre rule, which otherwise adds a stray top margin. */
.tg-generator .tg-output {
flex: 1;
min-width: 0;
margin: 0;
padding: 6px 9px;
background: #f5f5f5;
color: var(--md-default-fg-color);
border: 1px solid #ddd;
border-radius: 6px;
overflow-x: auto;
font-family: var(--md-code-font-family, monospace);
font-size: 0.72rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
overflow-wrap: anywhere;
}
/* Dark mode */
body[data-md-color-scheme="slate"] .tg-generator {
background: #1e1e2e;
border-color: #444;
}
body[data-md-color-scheme="slate"] .tg-header {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .tg-title {
color: #ddd;
}
body[data-md-color-scheme="slate"] .tg-reset {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .tg-reset:hover {
border-color: #888;
color: #ddd;
}
body[data-md-color-scheme="slate"] .tg-left {
border-right-color: #444;
}
body[data-md-color-scheme="slate"] .tg-field > label,
body[data-md-color-scheme="slate"] .tg-output-label {
color: #aaa;
}
body[data-md-color-scheme="slate"] .tg-btn-copy {
color: #888;
}
body[data-md-color-scheme="slate"] .tg-btn-copy:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .tg-field input[type="text"] {
background: #2a2a3a;
border-color: #555;
color: #ddd;
}
body[data-md-color-scheme="slate"] .tg-note {
color: #777;
}
body[data-md-color-scheme="slate"] .tg-output {
background: #161620;
border-color: #444;
}
/* Responsive: stack panels like the config generator does on mobile */
@media (max-width: 700px) {
.tg-body {
flex-direction: column;
}
.tg-left {
border-right: none;
border-bottom: 1px solid #ddd;
}
body[data-md-color-scheme="slate"] .tg-left {
border-bottom-color: #444;
}
}
+121
View File
@@ -0,0 +1,121 @@
// Topic name generator for the ntfy docs
//
// A tiny helper that lives on the "Publishing" page. The user types a memorable
// prefix (e.g. "backups"), and the widget appends a random, hard-to-guess suffix
// (e.g. "backups-x7Kp2mQ9"). The result is a valid, unguessable topic name.
//
// Topic names on the server must match ^[-_A-Za-z0-9]{1,64}$ (see server.go), so as
// the user types we strip anything that isn't allowed (spaces, slashes, punctuation,
// emoji, ...) live and cap the whole thing at 64 characters. The random suffix is
// generated once on load and can be re-rolled with the "Regenerate suffix" button.
(function () {
// Allowed topic characters per the server regex ^[-_A-Za-z0-9]{1,64}$
const ALLOWED = /[^-_A-Za-z0-9]/g;
const MAX_LEN = 64;
// Suffix alphabet: full base62 (letters + digits). We deliberately keep look-alikes
// (0/O, l/1) for maximum entropy -- this is a generated suffix, not something typed by
// hand. Hyphen/underscore are excluded so the "-" separator stays visually clear.
const SUFFIX_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const SUFFIX_LEN = 10;
// randomSuffix returns a cryptographically random string from SUFFIX_ALPHABET.
// It uses rejection sampling to avoid the modulo bias that a plain `byte % 62` would
// introduce (256 is not a multiple of 62), keeping every character equally likely.
function randomSuffix() {
const n = SUFFIX_ALPHABET.length;
const limit = Math.floor(256 / n) * n; // largest multiple of n that fits in a byte
const buf = new Uint8Array(1);
let out = "";
while (out.length < SUFFIX_LEN) {
crypto.getRandomValues(buf);
if (buf[0] < limit) {
out += SUFFIX_ALPHABET[buf[0] % n];
}
}
return out;
}
// sanitize strips everything that isn't a valid topic character.
function sanitize(value) {
return value.replace(ALLOWED, "");
}
function initTopicGenerator() {
const root = document.getElementById("tg-widget");
if (!root) return;
const input = root.querySelector("#tg-input");
const outputName = root.querySelector("#tg-output-name");
const outputUrl = root.querySelector("#tg-output-url");
const reroll = root.querySelector("#tg-reroll");
let suffix = randomSuffix();
// update recomputes the live preview from the (sanitized) input + current suffix.
function update() {
// Sanitize in place so the user sees disallowed characters disappear as they type.
const cleaned = sanitize(input.value);
if (cleaned !== input.value) {
const pos = input.selectionStart - (input.value.length - cleaned.length);
// Reassigning .value and setSelectionRange make the browser scroll the field into
// view (there is no preventScroll option for setSelectionRange), which jumps the
// whole page. Capture the scroll position and restore it afterwards.
const scrollX = window.scrollX;
const scrollY = window.scrollY;
input.value = cleaned;
// Best-effort caret restore so removing a bad char doesn't jump the cursor to the end.
try { input.setSelectionRange(pos, pos); } catch { /* ignore */ }
window.scrollTo(scrollX, scrollY);
}
// Compose "<prefix>-<suffix>", capped at the 64-char topic limit. With no prefix,
// fall back to just the random suffix so the output is always a valid topic.
let topic;
if (cleaned === "") {
topic = suffix;
} else {
const maxPrefix = MAX_LEN - suffix.length - 1; // room for "-" + suffix
const prefix = cleaned.slice(0, Math.max(0, maxPrefix));
topic = prefix === "" ? suffix : prefix + "-" + suffix;
}
outputName.textContent = topic;
outputUrl.textContent = "https://ntfy.sh/" + topic;
}
input.addEventListener("input", update);
reroll.addEventListener("click", function () {
suffix = randomSuffix();
update();
input.focus();
});
// Copy buttons: copy the target output and briefly swap the clipboard icon for a checkmark,
// mirroring the config generator's copy button behavior.
const copyIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>";
const checkIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"20 6 9 17 4 12\"></polyline></svg>";
root.querySelectorAll(".tg-btn-copy").forEach(function (btn) {
btn.addEventListener("click", function () {
const target = root.querySelector("#" + btn.dataset.copy);
if (!target || !target.textContent) return;
navigator.clipboard.writeText(target.textContent).then(function () {
btn.innerHTML = checkIcon;
btn.style.color = "var(--md-primary-fg-color)";
setTimeout(function () {
btn.innerHTML = copyIcon;
btn.style.color = "";
}, 2000);
});
});
});
update();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initTopicGenerator);
} else {
initTopicGenerator();
}
})();
+22 -22
View File
@@ -4,22 +4,22 @@ go 1.25.8
require (
cloud.google.com/go/firestore v1.22.0 // indirect
cloud.google.com/go/storage v1.62.2 // indirect
cloud.google.com/go/storage v1.63.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.24.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.44
github.com/mattn/go-sqlite3 v1.14.47
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.52.0
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
golang.org/x/time v0.15.0
google.golang.org/api v0.283.0
google.golang.org/api v0.287.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -34,8 +34,8 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
golang.org/x/sys v0.46.0
golang.org/x/text v0.39.0
)
require (
@@ -45,12 +45,12 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/longrunning v1.0.0 // indirect
cloud.google.com/go/longrunning v1.1.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -60,7 +60,7 @@ require (
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -69,7 +69,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -79,10 +79,10 @@ require (
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@@ -94,12 +94,12 @@ require (
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/net v0.56.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.82.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+48 -48
View File
@@ -14,12 +14,12 @@ cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE=
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4=
cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ=
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
@@ -28,14 +28,14 @@ github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
@@ -66,8 +66,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -139,16 +139,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4=
github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
@@ -173,8 +173,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
@@ -195,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -211,8 +211,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -222,8 +222,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -236,8 +236,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -247,8 +247,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -260,8 +260,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -274,18 +274,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik=
google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4=
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+12 -66
View File
@@ -1,4 +1,4 @@
package server
package mail
import (
_ "embed" // required by go:embed
@@ -6,66 +6,24 @@ import (
"fmt"
"mime"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
type mailer interface {
Send(v *visitor, m *model.Message, to string) error
Counts() (total int64, success int64, failure int64)
}
var (
//go:embed "mailer_emoji_map.json"
emojisJSON string
type smtpSender struct {
config *Config
sender *mail.Sender
success int64
failure int64
mu sync.Mutex
}
// emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init
emojiMap map[string]string
)
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
return s.withCount(v, m, func() error {
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
if err != nil {
return err
}
ev := logvm(v, m).
Tag(tagEmail).
Fields(log.Context{
"email_via": s.sender.Addr(),
"email_user": s.sender.User(),
"email_to": to,
})
if ev.IsTrace() {
ev.Field("email_body", message).Trace("Sending email")
}
ev.Info("Sending email")
return s.sender.SendRaw(to, []byte(message))
})
}
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.success + s.failure, s.success, s.failure
}
func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {
err := fn()
s.mu.Lock()
defer s.mu.Unlock()
if err != nil {
logvm(v, m).Err(err).Debug("Sending mail failed")
s.failure++
} else {
s.success++
func init() {
if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
panic("mail: invalid embedded emoji map: " + err.Error())
}
return err
}
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
@@ -78,10 +36,7 @@ func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, e
message := m.Message
trailer := ""
if len(m.Tags) > 0 {
emojis, tags, err := toEmojis(m.Tags)
if err != nil {
return "", err
}
emojis, tags := toEmojis(m.Tags)
if len(emojis) > 0 {
subject = strings.Join(emojis, " ") + " " + subject
}
@@ -126,16 +81,7 @@ This message was sent by {ip} at {time} via {topicURL}`
return body, nil
}
var (
//go:embed "mailer_emoji_map.json"
emojisJSON string
)
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
var emojiMap map[string]string
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
return nil, nil, err
}
func toEmojis(tags []string) (emojisOut []string, tagsOut []string) {
tagsOut = make([]string, 0)
emojisOut = make([]string, 0)
for _, t := range tags {
@@ -1,4 +1,4 @@
package server
package mail
import (
"testing"
+80 -94
View File
@@ -10,82 +10,94 @@ import (
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/model"
)
const (
verifyCodeExpiry = 10 * time.Minute
verifyCodeLength = 6
verifyCodeSubject = "ntfy email verification"
tagMail = "mail"
emailVerificationSubject = "Verify your email for ntfy"
passwordResetSubject = "Reset your ntfy password"
)
// Config holds the SMTP configuration for the mail sender
type Config struct {
BaseURL string // ntfy base URL, used to build topic URLs in notification emails
SMTPAddr string // SMTP server address (host:port)
SMTPUser string // SMTP auth username
SMTPPass string // SMTP auth password
From string // Sender email address
}
// Sender sends emails and manages email verification codes
type Sender struct {
config *Config
codes map[string]verifyCode // Verification codes, keyed by email
mu sync.Mutex
closeChan chan struct{}
// Sender sends all of ntfy's outgoing email: notification emails (the email-on-publish feature)
// as well as the magic-link emails for email verification and password reset. realSender is the
// SMTP-backed implementation; tests inject a fake.
type Sender interface {
SendNotification(to string, m *model.Message, senderIP string) error
NotificationCounts() (total int64, success int64, failure int64)
SendEmailVerification(to, link string) error
SendPasswordReset(to, link string) error
}
type verifyCode struct {
code string
expires time.Time
// realSender is the SMTP-backed implementation of Sender. Pending verification/reset state lives
// in the database (see user.Manager), not in this struct.
type realSender struct {
config *Config
success int64
failure int64
mu sync.Mutex
}
// NewSender creates a new mail Sender with the given SMTP config
func NewSender(config *Config) *Sender {
s := &Sender{
config: config,
codes: make(map[string]verifyCode),
closeChan: make(chan struct{}),
}
go s.expireLoop()
return s
func NewSender(config *Config) Sender {
return &realSender{config: config}
}
// Close stops the background expiry loop
func (s *Sender) Close() {
close(s.closeChan)
}
// Addr returns the SMTP server address
func (s *Sender) Addr() string {
return s.config.SMTPAddr
}
// User returns the SMTP username
func (s *Sender) User() string {
return s.config.SMTPUser
}
// From returns the sender email address
func (s *Sender) From() string {
return s.config.From
}
// SendRaw sends a raw email message via SMTP
func (s *Sender) SendRaw(to string, message []byte) error {
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
// SendNotification formats a ntfy message into a notification email and sends it via SMTP. It
// tracks success/failure counts, exposed via Counts (used for the server stats).
func (s *realSender) SendNotification(to string, m *model.Message, senderIP string) error {
message, err := formatMail(s.config.BaseURL, senderIP, s.config.From, to, m)
if err != nil {
s.count(false)
return err
}
var auth smtp.Auth
if s.config.SMTPUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
}
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email")
err = s.sendRaw(to, []byte(message))
s.count(err == nil)
return err
}
// Send sends a plain text email via SMTP
func (s *Sender) Send(to, subject, body string) error {
// NotificationCounts returns the number of notification emails sent, broken down into total, success and failure
func (s *realSender) NotificationCounts() (total int64, success int64, failure int64) {
s.mu.Lock()
defer s.mu.Unlock()
return s.success + s.failure, s.success, s.failure
}
// SendEmailVerification sends an email containing a magic link to verify ownership of the
// recipient address. The link carries a one-time token validated against the database.
func (s *realSender) SendEmailVerification(to, link string) error {
body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account:
%s
This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link)
return s.send(to, emailVerificationSubject, body)
}
// SendPasswordReset sends an email containing a magic link to set a new password. The link
// carries a one-time token validated against the database.
func (s *realSender) SendPasswordReset(to, link string) error {
body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account:
%s
This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link)
return s.send(to, passwordResetSubject, body)
}
// send sends a plain text email via SMTP
func (s *realSender) send(to, subject, body string) error {
date := time.Now().UTC().Format(time.RFC1123Z)
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
message := `From: ntfy <{from}>
@@ -100,55 +112,29 @@ Content-Type: text/plain; charset="utf-8"
message = strings.ReplaceAll(message, "{date}", date)
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
message = strings.ReplaceAll(message, "{body}", body)
log.Tag("mail").Field("email_to", to).Debug("Sending email")
return s.SendRaw(to, []byte(message))
log.Tag(tagMail).Field("email_to", to).Debug("Sending email")
return s.sendRaw(to, []byte(message))
}
// SendVerification generates a random code, stores it in-memory, and sends a verification email
func (s *Sender) SendVerification(to string) error {
code := util.RandomString(verifyCodeLength)
s.mu.Lock()
s.codes[to] = verifyCode{
code: code,
expires: time.Now().Add(verifyCodeExpiry),
// sendRaw sends a raw email message via SMTP
func (s *realSender) sendRaw(to string, message []byte) error {
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
if err != nil {
return err
}
s.mu.Unlock()
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
return s.Send(to, verifyCodeSubject, body)
var auth smtp.Auth
if s.config.SMTPUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
}
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
}
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
func (s *Sender) CheckVerification(email, code string) bool {
func (s *realSender) count(ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
vc, ok := s.codes[email]
if !ok || time.Now().After(vc.expires) || vc.code != code {
return false
}
delete(s.codes, email)
return true
}
func (s *Sender) expireLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.expireVerificationCodes()
case <-s.closeChan:
return
}
}
}
func (s *Sender) expireVerificationCodes() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for email, vc := range s.codes {
if now.After(vc.expires) {
delete(s.codes, email)
}
if ok {
s.success++
} else {
s.failure++
}
}
+2
View File
@@ -44,9 +44,11 @@ extra_javascript:
- static/js/extra.js
- static/js/bcrypt.js
- static/js/config-generator.js
- static/js/topic-generator.js
extra_css:
- static/css/extra.css
- static/css/config-generator.css
- static/css/topic-generator.css
markdown_extensions:
- admonition
+29
View File
@@ -22,6 +22,7 @@ func TestParseURL_Success(t *testing.T) {
require.Equal(t, "us-east-1", cfg.Region)
require.Equal(t, "AKID", cfg.AccessKey)
require.Equal(t, "SECRET", cfg.SecretKey)
require.Equal(t, "https", cfg.Scheme)
require.Equal(t, "s3.us-east-1.amazonaws.com", cfg.Endpoint)
require.False(t, cfg.PathStyle)
}
@@ -38,6 +39,7 @@ func TestParseURL_WithEndpoint(t *testing.T) {
require.Nil(t, err)
require.Equal(t, "my-bucket", cfg.Bucket)
require.Equal(t, "prefix", cfg.Prefix)
require.Equal(t, "https", cfg.Scheme)
require.Equal(t, "s3.example.com", cfg.Endpoint)
require.True(t, cfg.PathStyle)
}
@@ -45,10 +47,32 @@ func TestParseURL_WithEndpoint(t *testing.T) {
func TestParseURL_EndpointHTTP(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=http://localhost:9000")
require.Nil(t, err)
require.Equal(t, "http", cfg.Scheme)
require.Equal(t, "localhost:9000", cfg.Endpoint)
require.True(t, cfg.PathStyle)
}
func TestParseURL_EndpointNoScheme(t *testing.T) {
// A bare host:port endpoint (no scheme) must default to https for backward compatibility.
// Without this, url.Parse treats the host as the scheme ("localhost:9000" -> scheme "localhost").
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=localhost:9000")
require.Nil(t, err)
require.Equal(t, "https", cfg.Scheme)
require.Equal(t, "localhost:9000", cfg.Endpoint)
require.True(t, cfg.PathStyle)
require.Equal(t, "https://localhost:9000/my-bucket", cfg.BucketURL())
}
func TestParseURL_EndpointNoSchemeHostname(t *testing.T) {
// A dotted hostname with a port and no scheme must also default to https
// ("minio.example.com:9000" must not become scheme "minio.example.com").
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=minio.example.com:9000")
require.Nil(t, err)
require.Equal(t, "https", cfg.Scheme)
require.Equal(t, "minio.example.com:9000", cfg.Endpoint)
require.Equal(t, "https://minio.example.com:9000/my-bucket", cfg.BucketURL())
}
func TestParseURL_EndpointTrailingSlash(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=https://s3.example.com/")
require.Nil(t, err)
@@ -111,6 +135,11 @@ func TestConfig_BucketURL_PathStyle(t *testing.T) {
require.Equal(t, "https://s3.example.com/my-bucket", c.BucketURL())
}
func TestConfig_BucketURL_PathStyle_EndpointHTTP(t *testing.T) {
c := &Config{Scheme: "http", Endpoint: "localhost:9000", Bucket: "b", PathStyle: true}
require.Equal(t, "http://localhost:9000/b", c.BucketURL())
}
func TestConfig_BucketURL_VirtualHosted(t *testing.T) {
c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}
require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL())
+8 -3
View File
@@ -11,6 +11,7 @@ import (
// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string.
type Config struct {
Scheme string // URL scheme, e.g. "https" or "http"
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
PathStyle bool
Bucket string
@@ -24,10 +25,14 @@ type Config struct {
// BucketURL returns the base URL for bucket-level operations.
func (c *Config) BucketURL() string {
if c.PathStyle {
return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket)
scheme := "https"
if c.Scheme != "" {
scheme = c.Scheme
}
return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint)
if c.PathStyle {
return fmt.Sprintf("%s://%s/%s", scheme, c.Endpoint, c.Bucket)
}
return fmt.Sprintf("%s://%s.%s", scheme, c.Bucket, c.Endpoint)
}
// HostHeader returns the value for the Host header.
+10 -1
View File
@@ -70,21 +70,30 @@ func ParseURL(s3URL string) (*Config, error) {
return nil, fmt.Errorf("s3: region query parameter is required")
}
endpointParam := u.Query().Get("endpoint")
var scheme string
var endpoint string
var pathStyle bool
if endpointParam != "" {
// Custom endpoint: strip scheme prefix to extract host[:port]
// Custom endpoint: derive the scheme from the prefix and strip it to extract host[:port].
// Default to https for backward compatibility, including bare "host:port" endpoints (no
// scheme) -- url.Parse would otherwise misread the host before the port colon as the scheme.
scheme = "https"
if strings.HasPrefix(endpointParam, "http://") {
scheme = "http"
}
ep := strings.TrimRight(endpointParam, "/")
ep = strings.TrimPrefix(ep, "https://")
ep = strings.TrimPrefix(ep, "http://")
endpoint = ep
pathStyle = true
} else {
scheme = "https"
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region)
pathStyle = false
}
disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2"))
return &Config{
Scheme: scheme,
Endpoint: endpoint,
PathStyle: pathStyle,
Bucket: bucket,
+10 -1
View File
@@ -11,11 +11,20 @@ if [ -z "$1" ]; then
echo "Example:"
echo " $0 emoji-converted.json"
echo " $0 $ROOTDIR/web/src/app/emojis.js"
echo " $0 $ROOTDIR/web/src/app/emojisMapped.js"
echo " $0 $ROOTDIR/docs/emojis.md"
exit 1
fi
if [[ "$1" == *.js ]]; then
if [[ "$1" == *emojisMapped.js ]]; then
# Small alias -> emoji lookup used to render tags as emojis. Precomputed so the full
# emoji dataset (emojis.js) stays out of the main web bundle.
echo -n "// This file is generated by scripts/emoji-convert.sh -- alias to emoji lookup
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
export default " > "$1"
cat "$SCRIPTDIR/emoji.json" | jq -jc '[.[] | .aliases[] as $a | {key: $a, value: .emoji}] | from_entries' >> "$1"
echo ";" >> "$1"
elif [[ "$1" == *.js ]]; then
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
export const rawEmojis = " > "$1"
+1 -1
View File
@@ -71,7 +71,7 @@ const (
DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorTopicCreationLimitBurst = 100
DefaultVisitorTopicCreationLimitReplenish = time.Minute
DefaultVisitorAccountCreationLimitBurst = 3
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
DefaultVisitorAuthFailureLimitBurst = 30
DefaultVisitorAuthFailureLimitReplenish = time.Minute
+6 -3
View File
@@ -136,16 +136,18 @@ var (
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, define, or block", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil}
errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
@@ -156,6 +158,7 @@ var (
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
@@ -165,7 +168,7 @@ var (
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
+54 -349
View File
@@ -17,13 +17,11 @@ import (
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"text/template"
"time"
"unicode/utf8"
@@ -31,7 +29,7 @@ import (
"github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/action"
"heckel.io/ntfy/v2/attachment"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
@@ -42,7 +40,6 @@ import (
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
"heckel.io/ntfy/v2/webpush"
)
@@ -57,11 +54,11 @@ type Server struct {
unixListener net.Listener
smtpServer *smtp.Server
smtpServerBackend *smtpBackend
smtpSender mailer
mailSender *mail.Sender
mailer mail.Sender
topics map[string]*topic
visitors map[string]*visitor // ip:<ip> or user:<user>
firebaseClient *firebaseClient
twilio *twilioClient
messages int64 // Total number of messages (persisted if messageCache enabled)
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
userManager *user.Manager // Might be nil!
@@ -91,10 +88,16 @@ var (
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
deletePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/delete$`)
sequenceIDRegex = topicRegex
webConfigPath = "/config.js"
webManifestPath = "/manifest.webmanifest"
webAppConfigPath = "/config.js"
webAppManifestPath = "/manifest.webmanifest"
webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended
webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app)
webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended
webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app)
accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
@@ -116,6 +119,10 @@ var (
apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
apiAccountEmailPath = "/v1/account/email"
apiAccountEmailVerifyPath = "/v1/account/email/verify"
apiAccountEmailPrimaryPath = "/v1/account/email/primary"
apiAccountEmailResendPath = "/v1/account/email/resend"
apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request"
apiAccountPasswordResetPath = "/v1/account/password/reset"
apiAccountBillingPortalPath = "/v1/account/billing/portal"
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
@@ -143,10 +150,12 @@ var (
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
// are not useful, and seem potentially troublesome.
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
// mutated in production.
templateMaxExecutionTime = 100 * time.Millisecond
)
const (
@@ -160,7 +169,6 @@ const (
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
messagesHistoryMax = 10 // Number of message count values to keep in memory
templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
@@ -176,16 +184,15 @@ const (
// New instantiates a new Server. It creates the cache and adds a Firebase
// subscriber (if configured).
func New(conf *Config) (*Server, error) {
var mailer mailer
var mailSender *mail.Sender
var sender mail.Sender
if conf.SMTPSenderAddr != "" {
mailSender = mail.NewSender(&mail.Config{
sender = mail.NewSender(&mail.Config{
BaseURL: conf.BaseURL,
SMTPAddr: conf.SMTPSenderAddr,
SMTPUser: conf.SMTPSenderUser,
SMTPPass: conf.SMTPSenderPass,
From: conf.SMTPSenderFrom,
})
mailer = &smtpSender{config: conf, sender: mailSender}
}
var stripe stripeAPI
if payments.Available && conf.StripeSecretKey != "" {
@@ -290,8 +297,8 @@ func New(conf *Config) (*Server, error) {
webPush: wp,
attachment: attachmentStore,
firebaseClient: firebaseClient,
smtpSender: mailer,
mailSender: mailSender,
twilio: newTwilioClient(conf, userManager),
mailer: sender,
topics: topics,
userManager: userManager,
messages: messages,
@@ -443,9 +450,6 @@ func (s *Server) Stop() {
if s.smtpServer != nil {
s.smtpServer.Close()
}
if s.mailSender != nil {
s.mailSender.Close()
}
if s.attachment != nil {
s.attachment.Close()
}
@@ -542,7 +546,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" {
return s.ensureWebEnabled(s.handleRoot)(w, r, v)
return s.ensureWebEnabled(s.handleWebApp)(w, r, v)
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
@@ -551,9 +555,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureAdmin(s.handleVersion)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
return s.handleConfig(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
} else if r.Method == http.MethodGet && r.URL.Path == webAppConfigPath {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
} else if r.Method == http.MethodGet && r.URL.Path == webAppManifestPath {
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
@@ -611,12 +615,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailVerifyPath {
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountEmailVerify))(w, r, v) // No ensureUser: clicked from a mail client, possibly logged out
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailPrimaryPath {
return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath {
return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetRequestPath {
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordResetRequest))(w, r, v) // Unauthenticated
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetPath {
return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordReset))(w, r, v) // Unauthenticated
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
@@ -643,9 +655,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
} else if (r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path)) || (r.Method == http.MethodGet && deletePathRegex.MatchString(r.URL.Path)) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
} else if (r.Method == http.MethodGet || r.Method == http.MethodPut) && clearPathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
@@ -659,17 +671,14 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
} else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) {
return s.ensureWebEnabled(s.handleWebAppNoIndex)(w, r, v) // Magic-link landing pages (client-side routes)
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
}
return errHTTPNotFound
}
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error {
r.URL.Path = webAppIndex
return s.handleStatic(w, r, v)
}
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
if unifiedpush {
@@ -678,8 +687,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor)
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
return err
}
r.URL.Path = webAppIndex
return s.handleStatic(w, r, v)
return s.handleWebApp(w, r, v)
}
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
@@ -697,61 +705,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
return s.writeJSON(w, response)
}
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
w.Header().Set("Cache-Control", "no-cache")
return s.writeJSON(w, s.configResponse())
}
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
b, err := json.MarshalIndent(s.configResponse(), "", " ")
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/javascript")
w.Header().Set("Cache-Control", "no-cache")
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
return err
}
func (s *Server) configResponse() *apiConfigResponse {
return &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin,
RequireLogin: s.config.RequireLogin,
EnableSignup: s.config.EnableSignup,
EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
EnableEmailVerify: s.config.SMTPSenderVerify,
EnableReservations: s.config.EnableReservations,
EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact,
WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
ConfigHash: s.config.Hash(),
}
}
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &webManifestResponse{
Name: "ntfy",
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
ShortName: "ntfy",
Scope: "/",
StartURL: s.config.WebRoot,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: "#317f6f",
Icons: []*webManifestIcon{
{SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
{SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
},
}
return s.writeJSONWithContentType(w, response, "application/manifest+json")
}
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
// and listen-metrics-http is not set.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
@@ -759,19 +712,6 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visito
return nil
}
// handleStatic returns all static resources (excluding the docs), including the web app
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
return nil
}
// handleDocs returns static resources related to the docs
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
return nil
}
// handleStats returns the publicly available server stats
func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
s.mu.RLock()
@@ -901,7 +841,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
}
if call != "" {
var httpErr *errHTTP
call, httpErr = s.convertPhoneNumber(v.User(), call)
call, httpErr = s.twilio.convertPhoneNumber(v.User(), call)
if httpErr != nil {
return nil, httpErr.With(t)
} else if !vrate.CallAllowed() {
@@ -946,11 +886,11 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
if s.firebaseClient != nil && firebase {
go s.sendToFirebase(v, m)
}
if s.smtpSender != nil && email != "" {
if s.mailer != nil && email != "" {
go s.sendEmail(v, m, email)
}
if s.config.TwilioAccount != "" && call != "" {
go s.callPhone(v, r, m, call)
go s.twilio.callPhone(v, r, m, call)
}
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
go s.forwardPollRequest(v, m)
@@ -1108,7 +1048,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
if err := s.smtpSender.Send(v, m, email); err != nil {
if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
minc(metricEmailsPublishedFailure)
return
@@ -1208,7 +1148,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
}
if s.smtpSender == nil && email != "" {
if s.mailer == nil && email != "" {
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
}
call = readParam(r, "x-call", "call")
@@ -1259,7 +1199,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
}
actionsStr := readParam(r, "x-actions", "actions", "action")
if actionsStr != "" {
m.Actions, e = parseActions(actionsStr)
m.Actions, e = action.Parse(actionsStr)
if e != nil {
return false, false, "", "", "", false, "", errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
}
@@ -1309,7 +1249,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
} else if template.Enabled() {
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
return s.handleBodyAsTemplatedTextMessage(r.Context(), m, template, body, priorityStr) // Case 5
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 6
}
@@ -1345,114 +1285,6 @@ func (s *Server) handleBodyAsTextMessage(m *model.Message, body *util.PeekedRead
return nil
}
func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil {
return err
} else if body.LimitReached {
return errHTTPEntityTooLargeJSONBody
}
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if template.FileMode() {
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
return err
}
} else {
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
return err
}
}
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
return errHTTPBadRequestTemplateMessageTooLarge
}
return nil
}
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
// The template file must be in the templates directory, or in the configured template directory.
func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error {
if !templateNameRegex.MatchString(templateName) {
return errHTTPBadRequestTemplateFileNotFound
}
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
if s.config.TemplateDir != "" {
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
templateContent = b
}
}
if len(templateContent) == 0 {
return errHTTPBadRequestTemplateFileNotFound
}
var tpl templateFile
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
return errHTTPBadRequestTemplateFileInvalid
}
var err error
if tpl.Message != nil {
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
return err
}
}
if tpl.Title != nil {
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
return err
}
}
if tpl.Priority != nil {
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil
}
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message, title, and priority parameters.
func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error {
var err error
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
return err
}
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
return err
}
if priorityStr != "" {
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil
}
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
if templateDisallowedRegex.MatchString(tpl) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
var data any
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
if err != nil {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
}
var buf bytes.Buffer
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.Execute(limitWriter, data); err != nil {
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
}
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
}
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error {
if s.attachment == nil || s.config.BaseURL == "" {
return errHTTPBadRequestAttachmentsDisallowed.With(m)
@@ -1506,7 +1338,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *model.Message) (string, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
return "", err
}
return buf.String(), nil
@@ -1517,7 +1349,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *model.Message) (string, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
if err := util.EncodeJSON(&buf, msg.ForJSON()); err != nil {
return "", err
}
if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {
@@ -2236,130 +2068,6 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
}
}
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
return s.authorizeTopic(next, user.PermissionWrite)
}
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
return s.authorizeTopic(next, user.PermissionRead)
}
func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.userManager == nil {
return next(w, r, v)
}
topics, _, err := s.topicsFromPath(v, r.URL.Path)
if err != nil {
return err
}
u := v.User()
for _, t := range topics {
if err := s.userManager.Authorize(u, t.ID, perm); err != nil {
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
return errHTTPForbidden.With(t)
}
}
return next(w, r, v)
}
}
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
// if it is set.
//
// - If auth-file is not configured, immediately return an IP-based visitor
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
// an IP-based visitor is returned
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
// or the token (Bearer auth), and read the user from the database
//
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
// that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
// Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
vip := s.visitor(ip, nil)
if s.userManager == nil {
return vip, nil
}
header, err := readAuthHeader(r)
if err != nil {
return vip, err
} else if !supportedAuthHeader(header) {
return vip, nil
}
// If we're trying to auth, check the rate limiter first
if !vip.AuthAllowed() {
return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
}
u, err := s.authenticate(r, header)
if err != nil {
vip.AuthFailed()
logr(r).Err(err).Debug("Authentication failed")
return vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
}
// Authentication with user was successful
return s.visitor(ip, u), nil
}
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to
// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth
// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)).
func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) {
if strings.HasPrefix(header, "Bearer") {
return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer")))
}
return s.authenticateBasicAuth(r, header)
}
// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header,
// or from the ?auth... query parameter
func readAuthHeader(r *http.Request) (string, error) {
value := strings.TrimSpace(r.Header.Get("Authorization"))
queryParam := readQueryParam(r, "authorization", "auth")
if queryParam != "" {
a, err := base64.RawURLEncoding.DecodeString(queryParam)
if err != nil {
return "", err
}
value = strings.TrimSpace(string(a))
}
return value, nil
}
// supportedAuthHeader returns true only if the Authorization header value starts
// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither
// are things like "WebPush", or "vapid" (see #629).
func supportedAuthHeader(value string) bool {
value = strings.ToLower(value)
return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ")
}
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
r.Header.Set("Authorization", value)
username, password, ok := r.BasicAuth()
if !ok {
return nil, errors.New("invalid basic auth")
} else if username == "" {
return s.authenticateBearerAuth(r, password) // Treat password as token
}
return s.userManager.Authenticate(username, password)
}
func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) {
u, err := s.userManager.AuthenticateToken(token)
if err != nil {
return nil, err
}
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
LastAccess: time.Now(),
LastOrigin: ip,
})
return u, nil
}
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
s.mu.Lock()
defer s.mu.Unlock()
@@ -2381,10 +2089,7 @@ func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
func (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(v); err != nil {
return err
}
return nil
return util.EncodeJSON(w, v)
}
func (s *Server) updateAndWriteStats(messagesCount int64) {
+285 -59
View File
@@ -15,8 +15,10 @@ import (
)
const (
syncTopicAccountSyncEvent = "sync"
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
syncTopicAccountSyncEvent = "sync"
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter)
)
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -27,14 +29,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
} else if u != nil {
return errHTTPUnauthorized // Cannot create account from user context
}
if !v.AccountCreationAllowed() {
return errHTTPTooManyRequestsLimitAccountCreation
if !v.AccountActionAllowed() {
return errHTTPTooManyRequestsLimitAccountActions
}
}
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
return errHTTPConflictUserExists
}
@@ -45,7 +50,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
}
return err
}
v.AccountCreated()
v.AccountActionPerformed()
// If an email was provided and email sending is configured, start verification (best-effort).
// The address becomes the primary email on verify (the new account has no primary yet); a
// failure to send must not fail signup, so we only log it.
if newAccount.Email != "" && s.mailer != nil {
if u, err := s.userManager.User(newAccount.Username); err != nil {
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification")
} else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil {
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification")
}
}
return s.writeJSON(w, newSuccessResponse())
}
@@ -92,6 +107,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
if u.Prefs.Language != nil {
response.Language = *u.Prefs.Language
}
if u.Prefs.DateFormat != nil {
response.DateFormat = *u.Prefs.DateFormat
}
if u.Prefs.TimeFormat != nil {
response.TimeFormat = *u.Prefs.TimeFormat
}
if u.Prefs.Notification != nil {
response.Notification = u.Prefs.Notification
}
@@ -160,13 +181,25 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.PhoneNumbers = phoneNumbers
}
}
if s.mailSender != nil {
if s.mailer != nil {
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
}
if len(emails) > 0 {
response.Emails = emails
pendingEmails, err := s.userManager.PendingEmails(u.ID)
if err != nil {
return err
}
// Combine verified (with primary flag) and pending (unverified) into one list
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
for _, email := range emails {
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email.Address, Primary: email.Primary})
}
for _, email := range pendingEmails {
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
}
if len(emailInfos) > 0 {
response.Emails = emailInfos
}
}
} else {
@@ -346,6 +379,12 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
if newPrefs.Language != nil {
prefs.Language = newPrefs.Language
}
if newPrefs.DateFormat != nil {
prefs.DateFormat = newPrefs.DateFormat
}
if newPrefs.TimeFormat != nil {
prefs.TimeFormat = newPrefs.TimeFormat
}
if newPrefs.Notification != nil {
if prefs.Notification == nil {
prefs.Notification = &user.NotificationPrefs{}
@@ -574,7 +613,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
}
// Actually add the unverified number, and send verification
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
if err := s.twilio.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
@@ -589,7 +628,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
if err := s.twilio.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
return err
}
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
@@ -615,83 +654,254 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
// handleAccountEmailAdd starts email verification (PUT /v1/account/email): it generates a
// magic-link token, stores a pending verification, and emails the link. The address is NOT
// added to the verified list until the user clicks the link (handleAccountEmailVerify).
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
// Check user is allowed to add emails
if u == nil {
return errHTTPUnauthorized
} else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
// Check user is allowed to add emails (the tier email limit gates the feature)
if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
return errHTTPUnauthorized
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
return errHTTPUnauthorized
}
// Check if email already exists
// Reject if already verified on this account (pending re-requests are fine -- they replace)
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
} else if util.Contains(emails, req.Email) {
} else if emails.Contains(req.Email) {
return errHTTPConflictEmailExists
}
// Check email rate limit (counts against the user's email quota)
// Rate limit (counts against the user's email quota)
if !v.EmailAllowed() {
return errHTTPTooManyRequestsLimitEmails
}
// Send verification email
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
if err := s.mailSender.SendVerification(req.Email); err != nil {
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
// handleAccountEmailVerify performs verification from the (unauthenticated) landing page
// (POST /v1/account/email/verify): it validates the raw token, adds the address to the user's
// verified emails, and -- if the user has no primary yet -- promotes it. No auth is required;
// the token binds the action to a user, so the click works from a logged-out mail client.
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if req.Token == "" {
return errHTTPBadRequestEmailVerificationLinkInvalid
}
m, err := s.userManager.VerifyEmail(req.Token)
if errors.Is(err, user.ErrMagicLinkNotFound) {
return errHTTPBadRequestEmailVerificationLinkInvalid
} else if err != nil {
return err
}
logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified")
// Refresh the verified user's other sessions. The request is unauthenticated (v.User() is
// usually nil), so resolve the user from the token row and publish to their sync topic.
s.publishSyncEventForUserIDAsync(v, m.UserID)
return s.writeJSON(w, newSuccessResponse())
}
// handleAccountEmailDelete removes an email address, whether verified or still pending
// (DELETE /v1/account/email). Removing the primary leaves the account with no primary.
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
} else if !s.mailSender.CheckVerification(req.Email, req.Code) {
return errHTTPBadRequestEmailVerificationCodeInvalid
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified")
if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)")
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
return err
}
// Also drop any pending verification for the address (no-op if there is none)
if err := s.userManager.DeleteEmailVerification(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
// convertEmailAddress checks the email address against the user's verified email list.
// If smtp-sender-verify is false (default), the email is passed through as-is for
// backwards compatibility. If true, the user must be authenticated and the email must be
// in their verified list. "yes"/"true"/"1" resolves to the first verified email.
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
if !s.config.SMTPSenderVerify {
if toBool(email) {
return "", errHTTPBadRequestEmailAddressInvalid
// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery)
// email (POST /v1/account/email/primary).
func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
err = s.userManager.SetPrimaryEmail(u.ID, req.Email)
if errors.Is(err, user.ErrEmailPrimaryElsewhere) {
return errHTTPConflictEmailPrimaryElsewhere
} else if errors.Is(err, user.ErrEmailNotFound) {
return errHTTPBadRequestEmailAddressNotVerified
} else if err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
// handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend).
func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
// Only resend for an address that is actually pending on this account
pending, err := s.userManager.PendingEmails(u.ID)
if err != nil {
return err
} else if !util.Contains(pending, req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
if !v.EmailAllowed() {
return errHTTPTooManyRequestsLimitEmails
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification")
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
// enqueueEmailVerification generates a magic-link token for the given address, stores the
// pending verification (replacing any existing one), and emails the link. Shared by the add,
// resend, signup, and Stripe paths. Requires base-url to build an absolute link.
func (s *Server) enqueueEmailVerification(userID, email string) error {
if s.config.BaseURL == "" {
return errHTTPInternalErrorMissingBaseURL
}
token, err := s.userManager.AddMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry)
if err != nil {
return err
}
link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token
return s.mailer.SendEmailVerification(email, link)
}
// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request,
// unauthenticated). It resolves the identifier (username or primary email) to at most one account
// and emails a reset link to that account's primary email. The response is always a uniform 200,
// regardless of whether anything matched, so it cannot be used to probe for accounts.
func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
// Rate limit via the shared per-visitor account-creation bucket (no new limiter/config)
if !v.AccountActionAllowed() {
return errHTTPTooManyRequestsLimitAccountActions
}
v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing
identifier := strings.TrimSpace(req.Identifier)
if identifier != "" && s.config.BaseURL != "" {
if userID, email, ok := s.resolveResetPasswordTarget(identifier); ok {
token, err := s.userManager.AddMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry)
if err != nil {
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token")
} else {
link := s.config.BaseURL + webAppPasswordResetPathPrefix + token
logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link")
if err := s.mailer.SendPasswordReset(email, link); err != nil {
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email")
}
}
} else {
logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)")
}
}
return s.writeJSON(w, newSuccessResponse())
}
// resolveResetPasswordTarget resolves a reset identifier (username or primary email) to a single account
// and its primary email. It applies the reset policy on top of the lookup: provisioned users are
// excluded, and ok=false is returned unless the account has a verified primary email (reset
// requires one, and that is where the link is sent).
func (s *Server) resolveResetPasswordTarget(identifier string) (userID string, email string, ok bool) {
u, err := s.userManager.UserByEmailOrUsername(identifier)
if err != nil || u == nil || u.Provisioned {
return "", "", false
}
primary, err := s.userManager.PrimaryEmail(u.ID)
if err != nil || primary == "" {
return "", "", false
}
return u.ID, primary, true
}
// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated):
// it validates the token and sets the new password. Existing access tokens stay valid.
func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if req.Token == "" {
return errHTTPBadRequestResetLinkInvalid
} else if req.Password == "" {
return errHTTPBadRequest
}
err = s.userManager.ResetPassword(req.Token, req.Password)
if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
} else if err != nil {
return err
}
logvr(v, r).Tag(tagAccount).Info("Password reset performed")
return s.writeJSON(w, newSuccessResponse())
}
// convertEmailAddress resolves the X-Email value to the address ntfy should send to.
//
// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is
// designated (e.g. a provisioned user), the first verified address (alphabetically). This is
// independent of smtp-sender-verify: it only requires an authenticated user with a verified
// address, since it means "send to my own email".
//
// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards
// compatible); when true, the address must be one the user has verified.
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
if toBool(email) {
if u == nil {
return "", errHTTPBadRequestAnonymousEmailNotAllowed
} else if s.userManager == nil {
return "", errHTTPBadRequestEmailAddressNotVerified
}
primary, err := s.userManager.PrimaryEmail(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if primary != "" {
return primary, nil
}
// No primary designated -> fall back to the first verified address, if any
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(emails) > 0 {
return emails[0].Address, nil
}
return "", errHTTPBadRequestEmailAddressNotVerified
}
// A literal address
if !s.config.SMTPSenderVerify {
return email, nil
} else if u == nil {
return "", errHTTPBadRequestAnonymousEmailNotAllowed
@@ -701,12 +911,7 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(emails) == 0 {
return "", errHTTPBadRequestEmailAddressNotVerified
}
if toBool(email) {
return emails[0], nil
} else if util.Contains(emails, email) {
} else if emails.Contains(email) {
return email, nil
}
return "", errHTTPBadRequestEmailAddressNotVerified
@@ -721,9 +926,30 @@ func (s *Server) publishSyncEventAsync(v *visitor) {
}()
}
// publishSyncEvent publishes a sync message to the user's sync topic
// publishSyncEvent publishes a sync message to the authenticated user's sync topic
func (s *Server) publishSyncEvent(v *visitor) error {
u := v.User()
return s.publishSyncEventForUser(v, v.User())
}
// publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the
// given ID, resolving the user first. Used by the unauthenticated email-verify handler, where
// the request visitor has no associated user but the token identifies the account to refresh.
func (s *Server) publishSyncEventForUserIDAsync(v *visitor, userID string) {
go func() {
u, err := s.userManager.UserByID(userID)
if err != nil {
logv(v).Err(err).Trace("Error loading user for sync event")
return
}
if err := s.publishSyncEventForUser(v, u); err != nil {
logv(v).Err(err).Trace("Error publishing to user's sync topic")
}
}()
}
// publishSyncEventForUser publishes a sync message to the given user's sync topic, using v as
// the publishing visitor (for rate-limit accounting). No-op if the user has no sync topic.
func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
if u == nil || u.SyncTopic == "" {
return nil
}
+452
View File
@@ -0,0 +1,452 @@
package server
import (
"fmt"
"io"
"strings"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can
// "click" them without a real SMTP server. The notification side is a no-op.
type captureMailer struct {
verifyLinks map[string]string // email -> verification link
resetLinks map[string]string // email -> reset link
}
func newCaptureMailer() *captureMailer {
return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}}
}
func (c *captureMailer) SendEmailVerification(to, link string) error {
c.verifyLinks[to] = link
return nil
}
func (c *captureMailer) SendPasswordReset(to, link string) error {
c.resetLinks[to] = link
return nil
}
func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error {
return nil
}
func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) {
return 0, 0, 0
}
// newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured)
// and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth.
func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.BaseURL = "https://ntfy.example.com"
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailer = mailer
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")}
return s, mailer, auth
}
func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse {
rr := request(t, s, "GET", "/v1/account", "", auth)
require.Equal(t, 200, rr.Code)
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Nil(t, err)
return account
}
// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email
// list returned by GET /v1/account, so assertions stay readable.
func verifiedAddrs(account *apiAccountResponse) []string {
addrs := make([]string, 0)
for _, e := range account.Emails {
if !e.Pending {
addrs = append(addrs, e.Address)
}
}
return addrs
}
func pendingAddrs(account *apiAccountResponse) []string {
addrs := make([]string, 0)
for _, e := range account.Emails {
if e.Pending {
addrs = append(addrs, e.Address)
}
}
return addrs
}
func primaryAddr(account *apiAccountResponse) string {
for _, e := range account.Emails {
if e.Primary {
return e.Address
}
}
return ""
}
func tokenFromLink(t *testing.T, link, prefix string) string {
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
return strings.TrimPrefix(link, prefix)
}
func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
// Start verification
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
require.Equal(t, 200, rr.Code)
// Pending, not yet verified, no primary
account := getAccount(t, s, auth)
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
require.Empty(t, verifiedAddrs(account))
require.Equal(t, "", primaryAddr(account))
// "Click" the captured link (unauthenticated POST)
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil)
require.Equal(t, 200, rr.Code)
// Now verified + primary, no longer pending
account = getAccount(t, s, auth)
require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
require.Equal(t, "ben@example.com", primaryAddr(account))
require.Empty(t, pendingAddrs(account))
})
}
func TestAccount_Email_VerifyInvalidToken(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, _, _ := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
rr := request(t, s, "POST", "/v1/account/email/verify", `{"token":"doesnotexist"}`, nil)
require.Equal(t, 400, rr.Code)
require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code)
// Empty token also rejected
rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil)
require.Equal(t, 400, rr.Code)
})
}
func TestAccount_Email_DeletePending(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, _, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth)))
// Deleting the pending address clears it (no verification ever happened)
require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
account := getAccount(t, s, auth)
require.Empty(t, pendingAddrs(account))
require.Empty(t, verifiedAddrs(account))
})
}
func TestAccount_Email_Resend(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
firstLink := mailer.verifyLinks["ben@example.com"]
require.NotEmpty(t, firstLink)
// Resend issues a fresh link (the old one is replaced)
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code)
require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"])
// The old token no longer verifies; the new one does
oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code)
newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code)
// Resending for a non-pending address is rejected
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code)
})
}
func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
// ben verifies shared@ -> becomes his primary
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code)
benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code)
require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth)))
// alice verifies the same address -> allowed as secondary, but it is not her primary
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")}
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code)
aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
aliceAccount := getAccount(t, s, aliceAuth)
require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
require.Equal(t, "", primaryAddr(aliceAccount))
// alice trying to promote it to primary collides with ben's
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth)
require.Equal(t, 409, rr.Code)
require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code)
})
}
// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email.
func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) {
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code)
token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
}
// canLogin returns true if username/password authenticates (via the token-create endpoint).
func canLogin(t *testing.T, s *Server, username, password string) bool {
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)})
return rr.Code == 200
}
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
// Request reset by username
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
require.Equal(t, 200, rr.Code)
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
// Confirm with a new password
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
require.Equal(t, 200, rr.Code)
require.True(t, canLogin(t, s, "ben", "brandnew"))
require.False(t, canLogin(t, s, "ben", "ben"))
})
}
func TestAccount_PasswordReset_ByEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil)
require.Equal(t, 200, rr.Code)
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
require.Equal(t, 200, rr.Code)
require.True(t, canLogin(t, s, "ben", "brandnew"))
})
}
func TestAccount_PasswordReset_EmailLookalikeUsernameDoesNotShadow(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
// Account A (the email owner): user "ben" with verified primary email "phil@example.com"
verifyEmailFor(t, s, mailer, auth, "phil@example.com")
// Account B (the squatter): a different account whose USERNAME looks like A's email, with
// its own, different verified primary email
require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false))
squatter, err := s.userManager.User("phil@example.com")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com"))
// Reset by the ambiguous identifier: the verified email must win over the look-alike username
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil)
require.Equal(t, 200, rr.Code)
require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A)
require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B)
// The token resets account A (ben); the squatter's password is untouched
token := tokenFromLink(t, mailer.resetLinks["phil@example.com"], "https://ntfy.example.com/account/password/reset/")
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
require.Equal(t, 200, rr.Code)
require.True(t, canLogin(t, s, "ben", "brandnew")) // account A was reset
require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected
})
}
func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, _ := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
// Unknown identifier still returns a uniform 200, and no email is sent
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil)
require.Equal(t, 200, rr.Code)
require.Empty(t, mailer.resetLinks)
})
}
func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, _ := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
// ben exists but has no verified primary email -> uniform 200, nothing sent
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
require.Equal(t, 200, rr.Code)
require.Empty(t, mailer.resetLinks)
})
}
func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.EnableSignup = true
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.BaseURL = "https://ntfy.example.com"
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailer = mailer
defer s.closeDatabases()
// Sign up with an optional email -> account created and a verification link sent
rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil)
require.Equal(t, 200, rr.Code)
link := mailer.verifyLinks["emma@example.com"]
require.NotEmpty(t, link)
// Verifying the link makes it the (first) primary email
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")})
require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account))
require.Equal(t, "emma@example.com", primaryAddr(account))
})
}
func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.EnableSignup = true
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.BaseURL = "https://ntfy.example.com"
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailer = mailer
defer s.closeDatabases()
// No email -> account created, nothing sent
require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code)
require.Empty(t, mailer.verifyLinks)
// Invalid email -> rejected
rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil)
require.Equal(t, 400, rr.Code)
require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestAccount_Email_ProvisionedPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
require.Nil(t, err)
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.BaseURL = "https://ntfy.example.com"
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailer = mailer
defer s.closeDatabases()
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
// A provisioned user's first verified email becomes their primary (used by X-Email: yes;
// password reset stays blocked separately for provisioned users)
verifyEmailFor(t, s, mailer, auth, "prov@example.com")
account := getAccount(t, s, auth)
require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account))
require.Equal(t, "prov@example.com", primaryAddr(account))
// Verify a second address and explicitly set it primary -> allowed, star moves
verifyEmailFor(t, s, mailer, auth, "prov2@example.com")
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth)
require.Equal(t, 200, rr.Code)
account = getAccount(t, s, auth)
require.Equal(t, "prov2@example.com", primaryAddr(account))
})
}
func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Provision a user via config (AuthUsers), with email sending enabled
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.BaseURL = "https://ntfy.example.com"
conf.AuthUsers = []*user.User{
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
}
s := newTestServer(t, conf)
mailer := newCaptureMailer()
s.mailer = mailer
defer s.closeDatabases()
// Give the provisioned user a verified primary email anyway
prov, err := s.userManager.User("prov")
require.Nil(t, err)
require.True(t, prov.Provisioned)
require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com"))
// Reset request by username and by email -> uniform 200, but no email sent (can't reset)
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code)
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code)
require.Empty(t, mailer.resetLinks)
})
}
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, _, _ := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil)
require.Equal(t, 400, rr.Code)
require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code)
})
}
func TestAccount_Email_AddDuplicateVerified(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
// Adding the same already-verified address is a conflict
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
require.Equal(t, 409, rr.Code)
require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code)
})
}
+12 -3
View File
@@ -78,7 +78,8 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
s := newTestServer(t, conf)
defer s.closeDatabases()
for i := 0; i < 3; i++ {
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
for i := 0; i < 6; i++ {
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
require.Equal(t, 200, rr.Code)
}
@@ -131,7 +132,8 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) {
conf.EnableSignup = true
s := newTestServer(t, conf)
for i := 0; i < 3; i++ {
// Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests)
for i := 0; i < 6; i++ {
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
}
@@ -149,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
conf.VisitorAttachmentTotalSizeLimit = 5123
conf.AttachmentFileSizeLimit = 512
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
defer s.closeDatabases()
rr := request(t, s, "GET", "/v1/account", "", nil)
@@ -205,12 +207,19 @@ func TestAccount_ChangeSettings(t *testing.T) {
})
require.Equal(t, 200, rr.Code)
rr = request(t, s, "PATCH", "/v1/account/settings", `{"date_format": "iso8601", "time_format": "24h"}`, map[string]string{
"Authorization": util.BearerAuth(token.Value),
})
require.Equal(t, 200, rr.Code)
rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
"Authorization": util.BearerAuth(token.Value),
})
require.Equal(t, 200, rr.Code)
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
require.Equal(t, "de", account.Language)
require.Equal(t, "iso8601", account.DateFormat) // Merged, not overwritten by previous PATCH
require.Equal(t, "24h", account.TimeFormat)
require.Equal(t, util.Int(86400), account.Notification.DeleteAfter)
require.Equal(t, util.String("juntos"), account.Notification.Sound)
require.Nil(t, account.Notification.MinPriority) // Not set
+107
View File
@@ -0,0 +1,107 @@
package server
import (
"encoding/base64"
"errors"
"net/http"
"strings"
"time"
"heckel.io/ntfy/v2/user"
)
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
// if it is set.
//
// - If auth-file is not configured, immediately return an IP-based visitor
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
// an IP-based visitor is returned
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
// or the token (Bearer auth), and read the user from the database
//
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
// that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
// Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
vip := s.visitor(ip, nil)
if s.userManager == nil {
return vip, nil
}
header, err := readAuthHeader(r)
if err != nil {
return vip, err
} else if !supportedAuthHeader(header) {
return vip, nil
}
// If we're trying to auth, check the rate limiter first
if !vip.AuthAllowed() {
return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
}
u, err := s.authenticate(r, header)
if err != nil {
vip.AuthFailed()
logr(r).Err(err).Debug("Authentication failed")
return vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
}
// Authentication with user was successful
return s.visitor(ip, u), nil
}
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to
// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth
// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)).
func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) {
if strings.HasPrefix(header, "Bearer") {
return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer")))
}
return s.authenticateBasicAuth(r, header)
}
// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header,
// or from the ?auth... query parameter
func readAuthHeader(r *http.Request) (string, error) {
value := strings.TrimSpace(r.Header.Get("Authorization"))
queryParam := readQueryParam(r, "authorization", "auth")
if queryParam != "" {
a, err := base64.RawURLEncoding.DecodeString(queryParam)
if err != nil {
return "", err
}
value = strings.TrimSpace(string(a))
}
return value, nil
}
// supportedAuthHeader returns true only if the Authorization header value starts
// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither
// are things like "WebPush", or "vapid" (see #629).
func supportedAuthHeader(value string) bool {
value = strings.ToLower(value)
return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ")
}
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
r.Header.Set("Authorization", value)
username, password, ok := r.BasicAuth()
if !ok {
return nil, errors.New("invalid basic auth")
} else if username == "" {
return s.authenticateBearerAuth(r, password) // Treat password as token
}
return s.userManager.Authenticate(username, password)
}
func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) {
u, err := s.userManager.AuthenticateToken(token)
if err != nil {
return nil, err
}
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
LastAccess: time.Now(),
LastOrigin: ip,
})
return u, nil
}
+2 -2
View File
@@ -54,8 +54,8 @@ func (s *Server) execManager() {
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
}
var sentMailTotal, sentMailSuccess, sentMailFailure int64
if s.smtpSender != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
if s.mailer != nil {
sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts()
}
// Users
+1 -4
View File
@@ -165,8 +165,5 @@ func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
Rejected: rejected,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
return err
}
return nil
return util.EncodeJSON(w, response)
}
+30 -1
View File
@@ -3,6 +3,7 @@ package server
import (
"net/http"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -105,7 +106,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.mailSender == nil || s.userManager == nil {
if s.mailer == nil || s.userManager == nil {
return errHTTPNotFound
}
return next(w, r, v)
@@ -139,3 +140,31 @@ func (s *Server) withAccountSync(next handleFunc) handleFunc {
return err
}
}
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
return s.authorizeTopic(next, user.PermissionWrite)
}
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
return s.authorizeTopic(next, user.PermissionRead)
}
func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.userManager == nil {
return next(w, r, v)
}
topics, _, err := s.topicsFromPath(v, r.URL.Path)
if err != nil {
return err
}
u := v.User()
for _, t := range topics {
if err := s.userManager.Authorize(u, t.ID, perm); err != nil {
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
return errHTTPForbidden.With(t)
}
}
return next(w, r, v)
}
}
+31
View File
@@ -237,10 +237,41 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
return err
}
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
// Provisioned users can't reset their password, so recovery setup doesn't apply to them.
if sess.CustomerDetails != nil && !u.Provisioned {
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
}
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
return nil
}
// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's
// billing email, so they can use it for password recovery -- but only if they have no verified
// email yet and the billing email is not already the recovery email on another account. On a
// collision (or any other skip), the generic "no recovery email set" warning on the account page
// nudges the user to add one. This is best-effort: failures are logged, never surfaced.
func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) {
if s.mailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
return
}
emails, err := s.userManager.Emails(userID)
if err != nil {
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification")
return
} else if len(emails) > 0 {
return // User already has a verified email -- don't nag
}
if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil {
logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification")
return // Collision: skip + let the generic no-recovery-email warning nudge instead
}
logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email")
if err := s.enqueueEmailVerification(userID, billingEmail); err != nil {
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification")
}
}
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
+114
View File
@@ -0,0 +1,114 @@
//go:build !nopayments
package server
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stripe/stripe-go/v74"
"heckel.io/ntfy/v2/user"
)
// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given
// billing email on the session's CustomerDetails.
func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI {
m := &testStripeAPI{}
m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{
ClientReferenceID: u.ID,
Customer: &stripe.Customer{ID: "acct_5555"},
Subscription: &stripe.Subscription{ID: "sub_1234"},
CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail},
}, nil)
m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{
ID: "sub_1234",
Status: stripe.SubscriptionStatusActive,
CurrentPeriodEnd: 123456789,
Items: &stripe.SubscriptionItemList{
Data: []*stripe.SubscriptionItem{
{Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}},
},
},
}, nil)
m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil)
return m
}
func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.StripeSecretKey = "secret key"
c.BaseURL = "https://ntfy.example.com"
c.SMTPSenderAddr = "localhost:25"
c.SMTPSenderFrom = "noreply@example.com"
s := newTestServer(t, c)
mailer := newCaptureMailer()
s.mailer = mailer
require.Nil(t, s.userManager.AddTier(&user.Tier{
ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
return s, mailer, u
}
func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
defer s.closeDatabases()
s.stripe = stripeCheckoutMock(u, "billing@example.com")
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
require.Equal(t, 303, rr.Code)
// A verification link was auto-sent to the billing email; clicking it verifies + sets primary
link := mailer.verifyLinks["billing@example.com"]
require.NotEmpty(t, link)
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
emails, err := s.userManager.Emails(u.ID)
require.Nil(t, err)
require.Equal(t, []string{"billing@example.com"}, emails.Strings())
primary, err := s.userManager.PrimaryEmail(u.ID)
require.Nil(t, err)
require.Equal(t, "billing@example.com", primary)
})
}
func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
defer s.closeDatabases()
s.stripe = stripeCheckoutMock(u, "billing@example.com")
// User already has a verified email -> no auto-send on checkout
require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com"))
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
require.Equal(t, 303, rr.Code)
require.Empty(t, mailer.verifyLinks)
})
}
func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
defer s.closeDatabases()
s.stripe = stripeCheckoutMock(u, "billing@example.com")
// The billing email is already the recovery email on another account -> skip
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
alice, err := s.userManager.User("alice")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com"))
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
require.Equal(t, 303, rr.Code)
require.Empty(t, mailer.verifyLinks)
})
}
+190
View File
@@ -0,0 +1,190 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"text/template/parse"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/template/gotext"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
)
func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil {
return err
} else if body.LimitReached {
return errHTTPEntityTooLargeJSONBody
}
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if template.FileMode() {
if err := s.renderTemplateFromFile(ctx, m, template.FileName(), peekedBody); err != nil {
return err
}
} else {
if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil {
return err
}
}
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
return errHTTPBadRequestTemplateMessageTooLarge
}
return nil
}
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
// The template file must be in the templates directory, or in the configured template directory.
func (s *Server) renderTemplateFromFile(ctx context.Context, m *model.Message, templateName, peekedBody string) error {
if !templateNameRegex.MatchString(templateName) {
return errHTTPBadRequestTemplateFileNotFound
}
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
if s.config.TemplateDir != "" {
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
templateContent = b
}
}
if len(templateContent) == 0 {
return errHTTPBadRequestTemplateFileNotFound
}
var tpl templateFile
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
return errHTTPBadRequestTemplateFileInvalid
}
var err error
if tpl.Message != nil {
if m.Message, err = s.renderTemplate(ctx, templateName+" (message)", *tpl.Message, peekedBody); err != nil {
return err
}
}
if tpl.Title != nil {
if m.Title, err = s.renderTemplate(ctx, templateName+" (title)", *tpl.Title, peekedBody); err != nil {
return err
}
}
if tpl.Priority != nil {
renderedPriority, err := s.renderTemplate(ctx, templateName+" (priority)", *tpl.Priority, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil
}
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message, title, and priority parameters.
func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message, peekedBody string, priorityStr string) error {
var err error
if m.Message, err = s.renderTemplate(ctx, "priority query parameter", m.Message, peekedBody); err != nil {
return err
}
if m.Title, err = s.renderTemplate(ctx, "title query parameter", m.Title, peekedBody); err != nil {
return err
}
if priorityStr != "" {
renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil
}
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
var data any
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
if err != nil {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
}
if templateUsesDisallowedFeatures(t) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
// Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp). The deadline starts here, after the body
// has already been read, so a slow upload is not counted against it. Deriving from the request
// context means a client disconnect aborts the render too.
execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime)
defer cancel()
var buf bytes.Buffer
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", errHTTPBadRequestTemplateExecutionTimeout
}
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
}
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
}
// templateUsesDisallowedFeatures reports whether the parsed template defines or invokes a
// sub-template ({{define}}/{{block}}/{{template}}) or uses the {{call}} builtin. None are useful for
// ntfy's JSON-data templates. Checking the parse tree (rather than the raw string) catches every
// syntactic form -- e.g. {{if call .x}} or {{$y := call .x}} -- that a regex would miss.
func templateUsesDisallowedFeatures(t *gotext.Template) bool {
if len(t.Templates()) > 1 { // {{define}}/{{block}} create additional associated templates
return true
}
return treeContainsDisallowedNode(t.Root)
}
// treeContainsDisallowedNode reports whether the parse tree contains a {{template}}/{{block}}
// invocation or a {{call}} builtin, descending into pipes and command arguments (where {{call}} can
// appear anywhere a function is allowed).
func treeContainsDisallowedNode(node parse.Node) bool {
switch n := node.(type) {
case *parse.ListNode:
if n == nil {
return false
}
for _, child := range n.Nodes {
if treeContainsDisallowedNode(child) {
return true
}
}
case *parse.ActionNode:
return treeContainsDisallowedNode(n.Pipe)
case *parse.RangeNode:
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
case *parse.IfNode:
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
case *parse.WithNode:
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
case *parse.TemplateNode: // {{template}} or {{block}} invocation
return true
case *parse.PipeNode:
if n == nil {
return false
}
for _, cmd := range n.Cmds {
if treeContainsDisallowedNode(cmd) {
return true
}
}
case *parse.CommandNode:
for _, arg := range n.Args {
if treeContainsDisallowedNode(arg) {
return true
}
}
case *parse.IdentifierNode: // a function name; {{call}} is the disallowed builtin
return n.Ident == "call"
}
return false
}
+421 -27
View File
@@ -264,6 +264,27 @@ func TestServer_StaticSites(t *testing.T) {
})
}
func TestServer_WebApp_MagicLinkLandingPagesNoIndexHeaders(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
// Magic-link landing pages carry a one-time token in the path, so the response must not
// leak the token via the Referer header and must not be indexed
for _, path := range []string{"/account/email/verify/sometoken", "/account/password/reset/sometoken"} {
rr := request(t, s, "GET", path, "", nil)
require.Equal(t, 200, rr.Code, path)
require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"), path)
require.Equal(t, "noindex", rr.Header().Get("X-Robots-Tag"), path)
}
// Ordinary web app routes do not set these headers
rr := request(t, s, "GET", "/", "", nil)
require.Equal(t, 200, rr.Code)
require.Empty(t, rr.Header().Get("Referrer-Policy"))
require.Empty(t, rr.Header().Get("X-Robots-Tag"))
})
}
func TestServer_WebEnabled(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfig(t, databaseURL)
@@ -740,7 +761,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
func TestServer_PublishInvalidTopic(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
response := request(t, s, "PUT", "/docs", "fail", nil)
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
})
@@ -1231,7 +1252,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
c := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, c)
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
// Publish some messages, and check stats
for i := 0; i < 3; i++ {
@@ -1315,18 +1336,20 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
}
type testMailer struct {
count int
mu sync.Mutex
count int
lastTo string
mu sync.Mutex
}
func (t *testMailer) Send(v *visitor, m *model.Message, to string) error {
func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error {
t.mu.Lock()
defer t.mu.Unlock()
t.count++
t.lastTo = to
return nil
}
func (t *testMailer) Counts() (total int64, success int64, failure int64) {
func (t *testMailer) NotificationCounts() (total int64, success int64, failure int64) {
return 0, 0, 0
}
@@ -1336,6 +1359,16 @@ func (t *testMailer) Count() int {
return t.count
}
func (t *testMailer) LastTo() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastTo
}
func (t *testMailer) SendEmailVerification(to, link string) error { return nil }
func (t *testMailer) SendPasswordReset(to, link string) error { return nil }
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
@@ -1461,7 +1494,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com",
@@ -1481,7 +1514,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
c := newTestConfig(t, databaseURL)
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
s := newTestServer(t, c)
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
for i := 0; i < 16; i++ {
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
"E-Mail": "test@example.com",
@@ -1509,7 +1542,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
"E-Mail": "test@example.com",
"Delay": "20 min",
@@ -1546,7 +1579,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
func TestServer_PublishEmailAddressInvalid(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
addresses := []string{
"test@example.com, other@example.com",
"invalidaddress",
@@ -1572,7 +1605,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -1602,7 +1635,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -1628,17 +1661,97 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
})
}
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
mailer := &testMailer{}
s.mailer = mailer
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
// Two verified emails; the primary is NOT the alphabetically-first one
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
// "yes" must resolve to the primary email, not emails[0] (alphabetically first)
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, "zzz@example.com", mailer.LastTo())
})
}
func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
// smtp-sender-verify intentionally left false (the default)
s := newTestServer(t, conf)
mailer := &testMailer{}
s.mailer = mailer
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com"))
require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com"))
// Even with smtp-sender-verify off, "yes" resolves to the user's primary verified address
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, "zzz@example.com", mailer.LastTo())
})
}
func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
// "yes" without smtp-sender-verify should fail with invalid address
// "yes" requires an authenticated user (it means "my primary"); anonymous is rejected
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
require.Nil(t, err)
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
s := newTestServer(t, conf)
mailer := &testMailer{}
s.mailer = mailer
defer s.closeDatabases()
prov, err := s.userManager.User("prov")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(prov.ID, "aaa@example.com"))
require.Nil(t, s.userManager.AddEmail(prov.ID, "zzz@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "zzz@example.com"))
// A provisioned user's "yes" resolves to their chosen primary, not the alphabetically-first
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
"Authorization": util.BasicAuth("prov", "provpass"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, "zzz@example.com", mailer.LastTo())
})
}
@@ -1647,7 +1760,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
defer s.closeDatabases()
// Anonymous user should be rejected
@@ -1664,7 +1777,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -1682,7 +1795,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
s.mailer = &testMailer{}
// Without smtp-sender-verify, any email address should work (backwards compatible)
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
@@ -1706,11 +1819,11 @@ func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
// Create a user without a tier
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Verify email request should NOT return 401
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
// Starting email verification should NOT return 401
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
// The request will fail (SMTP not available), but it must NOT be a 401
// The request may fail (SMTP not available), but it must NOT be a 401
require.NotEqual(t, 401, response.Code)
})
}
@@ -1731,7 +1844,7 @@ func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Should be rejected with 401 since email sending is disabled
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, response.Code)
@@ -2139,7 +2252,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
t.Parallel()
mailer := &testMailer{}
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = mailer
s.mailer = mailer
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
@@ -3522,6 +3635,184 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
})
}
func TestServer_MessageTemplate_ExecutionTimeout(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// Nested range over a 1000-element JSON field with a no-output body: no Write ever happens,
// so the write-triggered TimeoutWriter never fires. Must be bounded by the executor's
// wall-clock deadline instead (GHSA-rhwf-xgc9-m9fp).
elems := make([]string, 1000)
for i := range elems {
elems[i] = "0"
}
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
msg := `{{range .a}}{{range $.a}}` + strings.Repeat(`{{$x := .}}`, 100) + `{{end}}{{end}}done`
start := time.Now()
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
"X-Message": msg,
"X-Template": "1",
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
require.Less(t, elapsed, 500*time.Millisecond, "template must be interrupted by the deadline, not run to completion (took %s)", elapsed)
})
}
// TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut is the regression for the exact hole the
// old write-triggered TimeoutWriter missed: a nested {{range}} over a JSON array field with a
// no-output body calls no function, so only the executor's wall-clock deadline can stop it
// (GHSA-rhwf-xgc9-m9fp).
func TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
elems := make([]string, 1000)
for i := range elems {
elems[i] = "0"
}
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
msg := `{{range .a}}{{range $.a}}{{range $.a}}{{$x := .}}{{end}}{{end}}{{end}}done`
start := time.Now()
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
"X-Message": msg,
"X-Template": "1",
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
require.Less(t, elapsed, 500*time.Millisecond, "data-driven nested range should be cut off by the deadline (took %s)", elapsed)
})
}
// TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut ensures the deadline also bounds loops
// whose body calls an expensive function (hashing a large string), where a single call between
// deadline checks could otherwise overshoot (GHSA-rhwf-xgc9-m9fp).
func TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
msg := `{{$big := repeat 990 "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"}}{{range until 1000}}{{range until 1000}}{{$h := sha512sum $big}}{{end}}{{end}}`
start := time.Now()
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": msg,
"X-Template": "1",
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
require.Less(t, elapsed, 1500*time.Millisecond, "expensive-function loop should be cut off by the deadline (took %s)", elapsed)
})
}
// TestServer_MessageTemplate_NestedLoopPoC_TimesOut is the exact proof-of-concept from the advisory:
// a range over a runtime-computed slice, nested, must be bounded by the deadline (GHSA-rhwf-xgc9-m9fp).
func TestServer_MessageTemplate_NestedLoopPoC_TimesOut(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
start := time.Now()
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
"X-Template": "1",
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
require.Less(t, elapsed, 500*time.Millisecond, "advisory PoC should be cut off by the deadline (took %s)", elapsed)
})
}
func TestServer_MessageTemplate_GenuineError_NotTimeout(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// A real runtime error (len of an int) must map to execute-failed, not the timeout code.
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{ len 5 }}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
})
}
// slowBody delivers its data after a delay, simulating a slow client upload of the request body.
type slowBody struct {
data []byte
delay time.Duration
done bool
}
func (b *slowBody) Read(p []byte) (int, error) {
if b.done {
return 0, io.EOF
}
time.Sleep(b.delay)
n := copy(p, b.data)
b.done = true
return n, nil
}
func (b *slowBody) Close() error { return nil }
// TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline verifies that a slow request-body
// upload does not consume the template execution deadline: the body is fully read (util.Peek)
// before the deadline starts, so a trivial template still renders even when the upload alone took
// longer than the deadline (GHSA-rhwf-xgc9-m9fp).
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
start := time.Now()
// The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the
// point is that the deadline starts at execution, not when the (slow) upload began.
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
"Template": "yes",
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
}, func(r *http.Request) {
r.Body = &slowBody{data: []byte(`{"foo":"bar"}`), delay: 3 * templateMaxExecutionTime}
})
elapsed := time.Since(start)
require.Greater(t, elapsed, templateMaxExecutionTime, "the slow upload must outlast the exec deadline for this test to be meaningful")
require.Equal(t, 200, response.Code) // Would be 40055 if upload time counted against the deadline
m := toMessage(t, response.Body.String())
require.Equal(t, "hello bar", m.Message)
}
// TestServer_MessageTemplate_ClientDisconnect_CancelsRender verifies that canceling the request
// context (e.g. the client disconnecting) aborts an in-progress template render. The execution
// deadline is raised well above the cancel delay for this test so that cancellation -- not the
// deadline -- is what stops the render: a runaway template is canceled 500ms in and must abort
// shortly after (well under the raised deadline), yielding the generic execute-failed code (40045),
// not the timeout code (40055).
//
// Not parallel: it temporarily raises the package-global templateMaxExecutionTime. Non-parallel
// tests run in their own phase (parallel tests are paused), so the override is race-free.
func TestServer_MessageTemplate_ClientDisconnect_CancelsRender(t *testing.T) {
origDeadline := templateMaxExecutionTime
templateMaxExecutionTime = 30 * time.Second // large enough that only the cancel can stop the render
defer func() { templateMaxExecutionTime = origDeadline }()
s := newTestServer(t, newTestConfig(t, ""))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(500 * time.Millisecond)
cancel()
}()
start := time.Now()
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
"X-Template": "1",
}, func(r *http.Request) {
*r = *r.WithContext(ctx)
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code, "a canceled render should map to execute-failed, not the timeout code 40055")
require.Greater(t, elapsed, 500*time.Millisecond, "render must still be running when the cancel fires (took %s)", elapsed)
require.Less(t, elapsed, 700*time.Millisecond, "request-context cancel should abort the render promptly after firing (took %s)", elapsed)
}
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
@@ -3616,11 +3907,18 @@ func TestServer_MessageTemplate_DisallowedCalls(t *testing.T) {
`{{- template ""}}`,
`{{-
template ""}}`,
`{{ call abc}}`,
`{{ define "aa"}}`,
`We cannot {{define "aa"}}`,
`{{ call "aa"}}`,
`{{define "aa"}}hi{{end}}`,
`We cannot {{define "aa"}}hi{{end}}`,
`We cannot {{ call "aa"}}`,
`We cannot {{- template "aa"}}`,
`{{block "aa" .}}hi{{end}}`,
`We cannot {{- block "aa" .}}hi{{end}}`,
// call is a function, not a keyword, so it can hide in non-leading positions that a
// raw-string regex misses -- the parse-tree walk catches all of them.
`{{if call .x}}x{{end}}`,
`{{$y := call .x}}`,
`{{index (call .x) 0}}`,
}
for _, disallowedTemplate := range disallowedTemplates {
messageTemplate := disallowedTemplate
@@ -4026,6 +4324,40 @@ func TestServer_DeleteMessage(t *testing.T) {
})
}
func TestServer_DeleteMessage_GET(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// Publish a message with a sequence ID
response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "seq123", msg.SequenceID)
require.Equal(t, "message", msg.Event)
// Delete the message using GET method (/topic/seq/delete)
response = request(t, s, "GET", "/mytopic/seq123/delete", "", nil)
require.Equal(t, 200, response.Code)
deleteMsg := toMessage(t, response.Body.String())
require.Equal(t, "seq123", deleteMsg.SequenceID)
require.Equal(t, "message_delete", deleteMsg.Event)
// Poll and verify both messages are returned
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
require.Equal(t, 2, len(lines))
msg1 := toMessage(t, lines[0])
msg2 := toMessage(t, lines[1])
require.Equal(t, "message", msg1.Event)
require.Equal(t, "message_delete", msg2.Event)
require.Equal(t, "seq123", msg1.SequenceID)
require.Equal(t, "seq123", msg2.SequenceID)
})
}
func TestServer_ClearMessage(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
@@ -4079,6 +4411,33 @@ func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
})
}
func TestServer_ClearMessage_GET(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// 1. Test GET /topic/seq-id/clear
response := request(t, s, "PUT", "/mytopic/seq456", "original message 1", nil)
require.Equal(t, 200, response.Code)
response = request(t, s, "GET", "/mytopic/seq456/clear", "", nil)
require.Equal(t, 200, response.Code)
clearMsg1 := toMessage(t, response.Body.String())
require.Equal(t, "seq456", clearMsg1.SequenceID)
require.Equal(t, "message_clear", clearMsg1.Event)
// 2. Test GET /topic/seq-id/read
response = request(t, s, "PUT", "/mytopic/seq789", "original message 2", nil)
require.Equal(t, 200, response.Code)
response = request(t, s, "GET", "/mytopic/seq789/read", "", nil)
require.Equal(t, 200, response.Code)
clearMsg2 := toMessage(t, response.Body.String())
require.Equal(t, "seq789", clearMsg2.SequenceID)
require.Equal(t, "message_clear", clearMsg2.Event)
})
}
func TestServer_UpdateMessage(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
@@ -4286,6 +4645,41 @@ func TestServer_DeleteScheduledMessage(t *testing.T) {
})
}
func TestServer_DeleteScheduledMessage_GET(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// Publish a scheduled message (future delivery)
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "delete-sched-seq", msg.SequenceID)
// Verify scheduled message exists
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "scheduled message to delete", messages[0].Message)
// Delete the scheduled message using GET method (/topic/seq/delete)
response = request(t, s, "GET", "/mytopic/delete-sched-seq/delete", "", nil)
require.Equal(t, 200, response.Code)
deleteMsg := toMessage(t, response.Body.String())
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
require.Equal(t, "message_delete", deleteMsg.Event)
// Verify scheduled message was deleted, only delete event remains
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
require.Equal(t, 200, response.Code)
messages = toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages))
require.Equal(t, "message_delete", messages[0].Event)
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
})
}
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
+34 -24
View File
@@ -16,6 +16,21 @@ import (
"heckel.io/ntfy/v2/util"
)
// twilioClient talks to the Twilio API to make phone calls (for the "Call" feature) and to verify
// phone numbers. It holds the Twilio configuration and the user manager (used to look up a user's
// verified phone numbers), so that this functionality is decoupled from the main Server.
type twilioClient struct {
config *Config
userManager *user.Manager // May be nil!
}
func newTwilioClient(conf *Config, userManager *user.Manager) *twilioClient {
return &twilioClient{
config: conf,
userManager: userManager,
}
}
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
// It can be overridden in the server configuration's twilio-call-format field.
//
@@ -52,11 +67,11 @@ type twilioCallData struct {
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
// If the user is anonymous, it will return an error.
func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
if u == nil {
return "", errHTTPBadRequestAnonymousCallsNotAllowed
}
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
phoneNumbers, err := c.userManager.PhoneNumbers(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if len(phoneNumbers) == 0 {
@@ -67,24 +82,19 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *
} else if util.Contains(phoneNumbers, phoneNumber) {
return phoneNumber, nil
}
for _, p := range phoneNumbers {
if p == phoneNumber {
return phoneNumber, nil
}
}
return "", errHTTPBadRequestPhoneNumberNotVerified
}
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
// Failures will be logged, but not returned to the caller.
func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
func (c *twilioClient) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
u, sender := v.User(), m.Sender.String()
if u != nil {
sender = u.Name
}
tmpl := defaultTwilioCallFormatTemplate
if s.config.TwilioCallFormat != nil {
tmpl = s.config.TwilioCallFormat
if c.config.TwilioCallFormat != nil {
tmpl = c.config.TwilioCallFormat
}
tags := make([]string, len(m.Tags))
for i, tag := range m.Tags {
@@ -106,11 +116,11 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to str
}
body := bodyBuf.String()
data := url.Values{}
data.Set("From", s.config.TwilioPhoneNumber)
data.Set("From", c.config.TwilioPhoneNumber)
data.Set("To", to)
data.Set("Twiml", body)
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
response, err := s.callPhoneInternal(data)
response, err := c.callPhoneInternal(data)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
minc(metricCallsMadeFailure)
@@ -120,15 +130,15 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to str
minc(metricCallsMadeSuccess)
}
func (s *Server) callPhoneInternal(data url.Values) (string, error) {
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount)
func (c *twilioClient) callPhoneInternal(data url.Values) (string, error) {
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.TwilioCallsBaseURL, c.config.TwilioAccount)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
@@ -140,19 +150,19 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
return string(response), nil
}
func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
func (c *twilioClient) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
@@ -166,19 +176,19 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
return nil
}
func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
func (c *twilioClient) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"heckel.io/ntfy/v2/util"
)
// handleWebApp serves the embedded web app's index for client-side (SPA) routes that the
// browser router resolves, so the app shell loads and the client-side router takes over.
func (s *Server) handleWebApp(w http.ResponseWriter, r *http.Request, v *visitor) error {
r.URL.Path = webAppIndex
return s.handleStatic(w, r, v)
}
// handleWebAppNoIndex serves the web app index for the magic-link landing pages, whose path
// carries a one-time token. The response is marked no-referrer (so the token can't leak to third
// parties via the Referer header) and noindex (so it never gets indexed).
func (s *Server) handleWebAppNoIndex(w http.ResponseWriter, r *http.Request, v *visitor) error {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Robots-Tag", "noindex")
return s.handleWebApp(w, r, v)
}
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
w.Header().Set("Cache-Control", "no-cache")
return s.writeJSON(w, s.configResponse())
}
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
b, err := json.MarshalIndent(s.configResponse(), "", " ")
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/javascript")
w.Header().Set("Cache-Control", "no-cache")
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
return err
}
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &webManifestResponse{
Name: "ntfy",
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
ShortName: "ntfy",
Scope: "/",
StartURL: s.config.WebRoot,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: "#317f6f",
Icons: []*webManifestIcon{
{SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
{SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
},
}
return s.writeJSONWithContentType(w, response, "application/manifest+json")
}
// handleStatic returns all static resources (excluding the docs), including the web app
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
return nil
}
// handleDocs returns static resources related to the docs
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
return nil
}
func (s *Server) configResponse() *apiConfigResponse {
return &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin,
RequireLogin: s.config.RequireLogin,
EnableSignup: s.config.EnableSignup,
EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url
EnableReservations: s.config.EnableReservations,
EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact,
WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
ConfigHash: s.config.Hash(),
}
}
+48 -20
View File
@@ -185,6 +185,7 @@ type apiAccessResetRequest struct {
type apiAccountCreateRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent
}
type apiAccountPasswordChangeRequest struct {
@@ -226,13 +227,29 @@ type apiAccountPhoneNumberAddRequest struct {
Code string `json:"code"` // Only set when adding a phone number
}
type apiAccountEmailVerifyRequest struct {
// apiAccountEmailRequest carries an email address for the add/delete/set-primary/resend
// endpoints (all of which identify an email by address in the JSON body).
type apiAccountEmailRequest struct {
Email string `json:"email"`
}
type apiAccountEmailAddRequest struct {
Email string `json:"email"`
Code string `json:"code"`
// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated)
// from the verification landing page.
type apiAccountEmailVerifyRequest struct {
Token string `json:"token"`
}
// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint.
// The identifier is a username or a primary email address.
type apiAccountPasswordResetRequest struct {
Identifier string `json:"identifier"`
}
// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm
// endpoint, submitted from the set-new-password landing page.
type apiAccountPasswordResetConfirmRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
type apiAccountTier struct {
@@ -271,6 +288,15 @@ type apiAccountReservation struct {
Everyone string `json:"everyone"`
}
// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account.
// Verified addresses have pending=false; exactly one verified address may be primary (the
// recovery email). Pending addresses are awaiting a magic-link click and are never primary.
type apiAccountEmailInfo struct {
Address string `json:"address"`
Primary bool `json:"primary,omitempty"`
Pending bool `json:"pending,omitempty"`
}
type apiAccountBilling struct {
Customer bool `json:"customer"`
Subscription bool `json:"subscription"`
@@ -286,12 +312,14 @@ type apiAccountResponse struct {
SyncTopic string `json:"sync_topic,omitempty"`
Provisioned bool `json:"provisioned,omitempty"`
Language string `json:"language,omitempty"`
DateFormat string `json:"date_format,omitempty"`
TimeFormat string `json:"time_format,omitempty"`
Notification *user.NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"`
Emails []string `json:"emails,omitempty"`
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"`
@@ -304,21 +332,21 @@ type apiAccountReservationRequest struct {
}
type apiConfigResponse struct {
BaseURL string `json:"base_url"`
AppRoot string `json:"app_root"`
EnableLogin bool `json:"enable_login"`
RequireLogin bool `json:"require_login"`
EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableEmailVerify bool `json:"enable_email_verify"`
EnableReservations bool `json:"enable_reservations"`
EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"`
WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
ConfigHash string `json:"config_hash"`
BaseURL string `json:"base_url"`
AppRoot string `json:"app_root"`
EnableLogin bool `json:"enable_login"`
RequireLogin bool `json:"require_login"`
EnableSignup bool `json:"enable_signup"`
EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableResetPassword bool `json:"enable_reset_password"`
EnableReservations bool `json:"enable_reservations"`
EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"`
WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
ConfigHash string `json:"config_hash"`
}
type apiAccountBillingPrices struct {
+7 -5
View File
@@ -66,7 +66,7 @@ type visitor struct {
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
firebase time.Time // Next allowed Firebase message
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
@@ -280,8 +280,9 @@ func (v *visitor) AuthFailed() {
}
}
// AccountCreationAllowed returns true if a new account can be created
func (v *visitor) AccountCreationAllowed() bool {
// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset
// request) is currently allowed for this visitor
func (v *visitor) AccountActionAllowed() bool {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
@@ -290,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool {
return true
}
// AccountCreated decreases the account limiter. This is to be called after an account was created.
func (v *visitor) AccountCreated() {
// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited
// account action (signup or password-reset request).
func (v *visitor) AccountActionPerformed() {
v.mu.RLock() // limiters could be replaced!
defer v.mu.RUnlock()
if v.accountLimiter != nil {
+1
View File
@@ -0,0 +1 @@
go1.26.5
+109
View File
@@ -0,0 +1,109 @@
# `template/gotext/` -- vendored `text/template` with context cancellation
This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one
small patch that adds context-aware execution (`ExecuteContext`). It exists for exactly one reason:
to stop **user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
from burning CPU.
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
- **Version:** pinned in the repo-root [`.go-version`](../../.go-version); recorded in `GENERATED_FROM`
- **Local modifications:** `patches/` (see [The patch](#the-patch))
- **Update mechanism:** Manual -- `make update-template`, then commit (never autorolled; see [Updating](#updating-when-bumping-the-go-toolchain))
## Why this exists
ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template`
**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation
([golang/go#31107](https://github.com/golang/go/issues/31107) proposed `ExecuteContext` but was
declined, over a bundled context-*values* feature, not cancellation itself). So a crafted template
with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
(GHSA-rhwf-xgc9-m9fp).
There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is
unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile
heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add
the cancellation half of #31107 as a patch: `ExecuteContext(ctx, ...)` that aborts with `ctx.Err()`
when `ctx` is canceled or its deadline passes. The check is a **single poll inside `walk`** of an
atomic flag that a `context.AfterFunc` watcher flips -- so it bounds CPU for *any* template shape
(cheap loops and expensive functions alike), it is exact (observed within one node), and it adds no
measurable overhead. If #31107's cancellation half ever lands upstream, this fork can be deleted and
the call site keeps compiling unchanged.
The one user-facing execution site (`server/server_template.go` `renderTemplate`) wraps execution in
`context.WithTimeout` and calls `ExecuteContext`, mapping `context.DeadlineExceeded` to a `400`.
Trusted templates (operator config: Twilio, `cmd/serve.go`) keep using the standard library -- they
are not user-supplied.
## What's here
| File | Origin |
|------|--------|
| `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically |
| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along |
| `patches/0001-exec-context.patch` | our only real change (see below) |
| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target |
The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version)
file (the single source of truth, also consumed by CI and the `make` targets below). `GENERATED_FROM`
must equal it -- `make check` fails otherwise (see below).
We do **not** vendor `text/template/parse` -- it's a normal importable stdlib package and stays a
plain import.
## The patch
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
`0001-exec-context.patch` -- small, purely additive, and touching only `exec.go`:
- adds `ctx context.Context` and a shared `cancelled *atomic.Bool` to the executor `state`
- adds `ExecuteContext` / `ExecuteTemplateContext`; `Execute` / `ExecuteTemplate` become
`context.Background()` wrappers, so their behavior and cost are unchanged
- when `ctx.Done() != nil`, arms one `context.AfterFunc` watcher that flips the flag; `walk` polls it
per node and aborts via a `cancelError` that `errRecover` strips to the bare `ctx.Err()`
(`errors.Is(err, context.DeadlineExceeded)`)
The flag is a `*atomic.Bool` (not a value) because `walkTemplate` copies `state` for nested
`{{template}}` invocations; a shared pointer keeps one flag across all copies and avoids `go vet`
copylocks. `template.go` is unchanged -- the context is per-call, not stored on the `Template`.
Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch --
renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to
`heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to
whatever files `go list` returns, so they survive upstream files being added or removed. (These two
transforms are also the only difference between our patch and the upstream `text/template` diff.)
Keeping the patch tiny (cancellation only, on one stable file) is deliberate: it makes re-basing
onto a new Go release cheap.
## Updating (when bumping the Go toolchain)
The copy is **pinned to the Go version in the root `.go-version`**, so it's not frozen -- re-syncing
on a Go bump pulls in all upstream fixes for free. `.go-version` is authoritative and hand-edited; to
bump the toolchain: edit `.go-version`, install that toolchain
(`go install golang.org/dl/<version>@latest && <version> download`), then re-sync:
```
make update-template # copies the files from your GOROOT and re-applies patches/*.patch
```
`make update-template` **errors** unless your local Go matches `.go-version` -- it validates against
the pin, it never writes it. If the patch hunks no longer apply against the new release, refresh the
patch as part of the bump.
`make template-check` (wired into `make check`) has two layers:
1. **Marker check (ungated, runs on any toolchain):** fails if `GENERATED_FROM` != `.go-version`, i.e.
someone bumped the pin but forgot `make update-template` (or vice versa). This catches the common
mistake locally, on any developer's Go.
2. **Content check (gated to the pinned Go):** re-derives the copy from `GOROOT + patches` and diffs it
against what's committed, catching hand-edits and patch problems. It no-ops on a non-pinned
toolchain so it never fails spuriously.
CI installs exactly `.go-version` (`go-version-file`), so both layers run there. `make release`
additionally refuses to run off the pinned Go, so the content check is never skipped for a release.
## License
These files are copyright The Go Authors, under the BSD-3-Clause license (headers preserved in each
file). That is compatible with ntfy's Apache-2.0 / GPLv2 licensing.
+502
View File
@@ -0,0 +1,502 @@
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
/*
Package template implements data-driven templates for generating textual output.
To generate HTML output, see [html/template], which has the same interface
as this package but automatically secures HTML output against certain attacks.
Templates are executed by applying them to a data structure. Annotations in the
template refer to elements of the data structure (typically a field of a struct
or a key in a map) to control execution and derive values to be displayed.
Execution of the template walks the structure and sets the cursor, represented
by a period '.' and called "dot", to the value at the current location in the
structure as execution proceeds.
The security model used by this package assumes that template authors are
trusted. The package does not auto-escape output, so injecting code into
a template can lead to arbitrary code execution if the template is executed
by an untrusted source.
The input text for a template is UTF-8-encoded text in any format.
"Actions"--data evaluations or control structures--are delimited by
"{{" and "}}"; all text outside actions is copied to the output unchanged.
Once parsed, a template may be executed safely in parallel, although if parallel
executions share a Writer the output may be interleaved.
Here is a trivial example that prints "17 items are made of wool".
type Inventory struct {
Material string
Count uint
}
sweaters := Inventory{"wool", 17}
tmpl, err := template.New("test").Parse("{{.Count}} items are made of {{.Material}}")
if err != nil { panic(err) }
err = tmpl.Execute(os.Stdout, sweaters)
if err != nil { panic(err) }
More intricate examples appear below.
Text and spaces
By default, all text between actions is copied verbatim when the template is
executed. For example, the string " items are made of " in the example above
appears on standard output when the program is run.
However, to aid in formatting template source code, if an action's left
delimiter (by default "{{") is followed immediately by a minus sign and white
space, all trailing white space is trimmed from the immediately preceding text.
Similarly, if the right delimiter ("}}") is preceded by white space and a minus
sign, all leading white space is trimmed from the immediately following text.
In these trim markers, the white space must be present:
"{{- 3}}" is like "{{3}}" but trims the immediately preceding text, while
"{{-3}}" parses as an action containing the number -3.
For instance, when executing the template whose source is
"{{23 -}} < {{- 45}}"
the generated output would be
"23<45"
For this trimming, the definition of white space characters is the same as in Go:
space, horizontal tab, carriage return, and newline.
Actions
Here is the list of actions. "Arguments" and "pipelines" are evaluations of
data, defined in detail in the corresponding sections that follow.
*/
// {{/* a comment */}}
// {{- /* a comment with white space trimmed from preceding and following text */ -}}
// A comment; discarded. May contain newlines.
// Comments do not nest and must start and end at the
// delimiters, as shown here.
/*
{{pipeline}}
The default textual representation (the same as would be
printed by fmt.Print) of the value of the pipeline is copied
to the output.
{{if pipeline}} T1 {{end}}
If the value of the pipeline is empty, no output is generated;
otherwise, T1 is executed. The empty values are false, 0, any
nil pointer or interface value, and any array, slice, map, or
string of length zero.
Dot is unaffected.
{{if pipeline}} T1 {{else}} T0 {{end}}
If the value of the pipeline is empty, T0 is executed;
otherwise, T1 is executed. Dot is unaffected.
{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}
To simplify the appearance of if-else chains, the else action
of an if may include another if directly; the effect is exactly
the same as writing
{{if pipeline}} T1 {{else}}{{if pipeline}} T0 {{end}}{{end}}
{{range pipeline}} T1 {{end}}
The value of the pipeline must be an array, slice, map, iter.Seq,
iter.Seq2, integer or channel.
If the value of the pipeline has length zero, nothing is output;
otherwise, dot is set to the successive elements of the array,
slice, or map and T1 is executed. If the value is a map and the
keys are of basic type with a defined order, the elements will be
visited in sorted key order.
{{range pipeline}} T1 {{else}} T0 {{end}}
The value of the pipeline must be an array, slice, map, iter.Seq,
iter.Seq2, integer or channel.
If the value of the pipeline has length zero, dot is unaffected and
T0 is executed; otherwise, dot is set to the successive elements
of the array, slice, or map and T1 is executed.
{{break}}
The innermost {{range pipeline}} loop is ended early, stopping the
current iteration and bypassing all remaining iterations.
{{continue}}
The current iteration of the innermost {{range pipeline}} loop is
stopped, and the loop starts the next iteration.
{{template "name"}}
The template with the specified name is executed with nil data.
{{template "name" pipeline}}
The template with the specified name is executed with dot set
to the value of the pipeline.
{{block "name" pipeline}} T1 {{end}}
A block is shorthand for defining a template
{{define "name"}} T1 {{end}}
and then executing it in place
{{template "name" pipeline}}
The typical use is to define a set of root templates that are
then customized by redefining the block templates within.
{{with pipeline}} T1 {{end}}
If the value of the pipeline is empty, no output is generated;
otherwise, dot is set to the value of the pipeline and T1 is
executed.
{{with pipeline}} T1 {{else}} T0 {{end}}
If the value of the pipeline is empty, dot is unaffected and T0
is executed; otherwise, dot is set to the value of the pipeline
and T1 is executed.
{{with pipeline}} T1 {{else with pipeline}} T0 {{end}}
To simplify the appearance of with-else chains, the else action
of a with may include another with directly; the effect is exactly
the same as writing
{{with pipeline}} T1 {{else}}{{with pipeline}} T0 {{end}}{{end}}
Arguments
An argument is a simple value, denoted by one of the following.
- A boolean, string, character, integer, floating-point, imaginary
or complex constant in Go syntax. These behave like Go's untyped
constants. Note that, as in Go, whether a large integer constant
overflows when assigned or passed to a function can depend on whether
the host machine's ints are 32 or 64 bits.
- The keyword nil, representing an untyped Go nil.
- The character '.' (period):
.
The result is the value of dot.
- A variable name, which is a (possibly empty) alphanumeric string
preceded by a dollar sign, such as
$piOver2
or
$
The result is the value of the variable.
Variables are described below.
- The name of a field of the data, which must be a struct, preceded
by a period, such as
.Field
The result is the value of the field. Field invocations may be
chained:
.Field1.Field2
Fields can also be evaluated on variables, including chaining:
$x.Field1.Field2
- The name of a key of the data, which must be a map, preceded
by a period, such as
.Key
The result is the map element value indexed by the key.
Key invocations may be chained and combined with fields to any
depth:
.Field1.Key1.Field2.Key2
Although the key must be an alphanumeric identifier, unlike with
field names they do not need to start with an upper case letter.
Keys can also be evaluated on variables, including chaining:
$x.key1.key2
- The name of a niladic method of the data, preceded by a period,
such as
.Method
The result is the value of invoking the method with dot as the
receiver, dot.Method(). Such a method must have one return value (of
any type) or two return values, the second of which is an error.
If it has two and the returned error is non-nil, execution terminates
and an error is returned to the caller as the value of Execute.
Method invocations may be chained and combined with fields and keys
to any depth:
.Field1.Key1.Method1.Field2.Key2.Method2
Methods can also be evaluated on variables, including chaining:
$x.Method1.Field
- The name of a niladic function, such as
fun
The result is the value of invoking the function, fun(). The return
types and values behave as in methods. Functions and function
names are described below.
- A parenthesized instance of one the above, for grouping. The result
may be accessed by a field or map key invocation.
print (.F1 arg1) (.F2 arg2)
(.StructValuedMethod "arg").Field
Arguments may evaluate to any type; if they are pointers the implementation
automatically indirects to the base type when required.
If an evaluation yields a function value, such as a function-valued
field of a struct, the function is not invoked automatically, but it
can be used as a truth value for an if action and the like. To invoke
it, use the call function, defined below.
Pipelines
A pipeline is a possibly chained sequence of "commands". A command is a simple
value (argument) or a function or method call, possibly with multiple arguments:
Argument
The result is the value of evaluating the argument.
.Method [Argument...]
The method can be alone or the last element of a chain but,
unlike methods in the middle of a chain, it can take arguments.
The result is the value of calling the method with the
arguments:
dot.Method(Argument1, etc.)
functionName [Argument...]
The result is the value of calling the function associated
with the name:
function(Argument1, etc.)
Functions and function names are described below.
A pipeline may be "chained" by separating a sequence of commands with pipeline
characters '|'. In a chained pipeline, the result of each command is
passed as the last argument of the following command. The output of the final
command in the pipeline is the value of the pipeline.
The output of a command will be either one value or two values, the second of
which has type error. If that second value is present and evaluates to
non-nil, execution terminates and the error is returned to the caller of
Execute.
Variables
A pipeline inside an action may initialize a variable to capture the result.
The initialization has syntax
$variable := pipeline
where $variable is the name of the variable. An action that declares a
variable produces no output.
Variables previously declared can also be assigned, using the syntax
$variable = pipeline
If a "range" action initializes a variable, the variable is set to the
successive elements of the iteration. Also, a "range" may declare two
variables, separated by a comma:
range $index, $element := pipeline
in which case $index and $element are set to the successive values of the
array/slice index or map key and element, respectively. Note that if there is
only one variable, it is assigned the element; this is opposite to the
convention in Go range clauses.
A variable's scope extends to the "end" action of the control structure ("if",
"with", or "range") in which it is declared, or to the end of the template if
there is no such control structure. A template invocation does not inherit
variables from the point of its invocation.
When execution begins, $ is set to the data argument passed to Execute, that is,
to the starting value of dot.
Examples
Here are some example one-line templates demonstrating pipelines and variables.
All produce the quoted word "output":
{{"\"output\""}}
A string constant.
{{`"output"`}}
A raw string constant.
{{printf "%q" "output"}}
A function call.
{{"output" | printf "%q"}}
A function call whose final argument comes from the previous
command.
{{printf "%q" (print "out" "put")}}
A parenthesized argument.
{{"put" | printf "%s%s" "out" | printf "%q"}}
A more elaborate call.
{{"output" | printf "%s" | printf "%q"}}
A longer chain.
{{with "output"}}{{printf "%q" .}}{{end}}
A with action using dot.
{{with $x := "output" | printf "%q"}}{{$x}}{{end}}
A with action that creates and uses a variable.
{{with $x := "output"}}{{printf "%q" $x}}{{end}}
A with action that uses the variable in another action.
{{with $x := "output"}}{{$x | printf "%q"}}{{end}}
The same, but pipelined.
Functions
During execution functions are found in two function maps: first in the
template, then in the global function map. By default, no functions are defined
in the template but the Funcs method can be used to add them.
Predefined global functions are named as follows.
and
Returns the boolean AND of its arguments by returning the
first empty argument or the last argument. That is,
"and x y" behaves as "if x then y else x."
Evaluation proceeds through the arguments left to right
and returns when the result is determined.
call
Returns the result of calling the first argument, which
must be a function, with the remaining arguments as parameters.
Thus "call .X.Y 1 2" is, in Go notation, dot.X.Y(1, 2) where
Y is a func-valued field, map entry, or the like.
The first argument must be the result of an evaluation
that yields a value of function type (as distinct from
a predefined function such as print). The function must
return either one or two result values, the second of which
is of type error. If the arguments don't match the function
or the returned error value is non-nil, execution stops.
html
Returns the escaped HTML equivalent of the textual
representation of its arguments. This function is unavailable
in html/template, with a few exceptions.
index
Returns the result of indexing its first argument by the
following arguments. Thus "index x 1 2 3" is, in Go syntax,
x[1][2][3]. Each indexed item must be a map, slice, or array.
slice
slice returns the result of slicing its first argument by the
remaining arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2],
while "slice x" is x[:], "slice x 1" is x[1:], and "slice x 1 2 3"
is x[1:2:3]. The first argument must be a string, slice, or array.
js
Returns the escaped JavaScript equivalent of the textual
representation of its arguments.
len
Returns the integer length of its argument.
not
Returns the boolean negation of its single argument.
or
Returns the boolean OR of its arguments by returning the
first non-empty argument or the last argument, that is,
"or x y" behaves as "if x then x else y".
Evaluation proceeds through the arguments left to right
and returns when the result is determined.
print
An alias for fmt.Sprint
printf
An alias for fmt.Sprintf
println
An alias for fmt.Sprintln
urlquery
Returns the escaped value of the textual representation of
its arguments in a form suitable for embedding in a URL query.
This function is unavailable in html/template, with a few
exceptions.
The boolean functions take any zero value to be false and a non-zero
value to be true.
There is also a set of binary comparison operators defined as
functions:
eq
Returns the boolean truth of arg1 == arg2
ne
Returns the boolean truth of arg1 != arg2
lt
Returns the boolean truth of arg1 < arg2
le
Returns the boolean truth of arg1 <= arg2
gt
Returns the boolean truth of arg1 > arg2
ge
Returns the boolean truth of arg1 >= arg2
For simpler multi-way equality tests, eq (only) accepts two or more
arguments and compares the second and subsequent to the first,
returning in effect
arg1==arg2 || arg1==arg3 || arg1==arg4 ...
(Unlike with || in Go, however, eq is a function call and all the
arguments will be evaluated.)
The comparison functions work on any values whose type Go defines as
comparable. For basic types such as integers, the rules are relaxed:
size and exact type are ignored, so any integer value, signed or unsigned,
may be compared with any other integer value. (The arithmetic value is compared,
not the bit pattern, so all negative integers are less than all unsigned integers.)
However, as usual, one may not compare an int with a float32 and so on.
Associated templates
Each template is named by a string specified when it is created. Also, each
template is associated with zero or more other templates that it may invoke by
name; such associations are transitive and form a name space of templates.
A template may use a template invocation to instantiate another associated
template; see the explanation of the "template" action above. The name must be
that of a template associated with the template that contains the invocation.
Nested template definitions
When parsing a template, another template may be defined and associated with the
template being parsed. Template definitions must appear at the top level of the
template, much like global variables in a Go program.
The syntax of such definitions is to surround each template declaration with a
"define" and "end" action.
The define action names the template being created by providing a string
constant. Here is a simple example:
{{define "T1"}}ONE{{end}}
{{define "T2"}}TWO{{end}}
{{define "T3"}}{{template "T1"}} {{template "T2"}}{{end}}
{{template "T3"}}
This defines two templates, T1 and T2, and a third T3 that invokes the other two
when it is executed. Finally it invokes T3. If executed this template will
produce the text
ONE TWO
By construction, a template may reside in only one association. If it's
necessary to have a template addressable from multiple associations, the
template definition must be parsed multiple times to create distinct *Template
values, or must be copied with [Template.Clone] or [Template.AddParseTree].
Parse may be called multiple times to assemble the various associated templates;
see [ParseFiles], [ParseGlob], [Template.ParseFiles] and [Template.ParseGlob]
for simple ways to parse related templates stored in files.
A template may be executed directly or through [Template.ExecuteTemplate], which executes
an associated template identified by name. To invoke our example above, we
might write,
err := tmpl.Execute(os.Stdout, "no data needed")
if err != nil {
log.Fatalf("execution failed: %s", err)
}
or to invoke a particular template explicitly by name,
err := tmpl.ExecuteTemplate(os.Stdout, "T2", "no data needed")
if err != nil {
log.Fatalf("execution failed: %s", err)
}
*/
package gotext
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package fmtsort provides a general stable ordering mechanism
// for maps, on behalf of the fmt and text/template packages.
// It is not guaranteed to be efficient and works only for types
// that are valid map keys.
package fmtsort
import (
"cmp"
"reflect"
"slices"
)
// Note: Throughout this package we avoid calling reflect.Value.Interface as
// it is not always legal to do so and it's easier to avoid the issue than to face it.
// SortedMap is a slice of KeyValue pairs that simplifies sorting
// and iterating over map entries.
//
// Each KeyValue pair contains a map key and its corresponding value.
type SortedMap []KeyValue
// KeyValue holds a single key and value pair found in a map.
type KeyValue struct {
Key, Value reflect.Value
}
// Sort accepts a map and returns a SortedMap that has the same keys and
// values but in a stable sorted order according to the keys, modulo issues
// raised by unorderable key values such as NaNs.
//
// The ordering rules are more general than with Go's < operator:
//
// - when applicable, nil compares low
// - ints, floats, and strings order by <
// - NaN compares less than non-NaN floats
// - bool compares false before true
// - complex compares real, then imag
// - pointers compare by machine address
// - channel values compare by machine address
// - structs compare each field in turn
// - arrays compare each element in turn.
// Otherwise identical arrays compare by length.
// - interface values compare first by reflect.Type describing the concrete type
// and then by concrete value as described in the previous rules.
func Sort(mapValue reflect.Value) SortedMap {
if mapValue.Type().Kind() != reflect.Map {
return nil
}
// Note: this code is arranged to not panic even in the presence
// of a concurrent map update. The runtime is responsible for
// yelling loudly if that happens. See issue 33275.
n := mapValue.Len()
sorted := make(SortedMap, 0, n)
iter := mapValue.MapRange()
for iter.Next() {
sorted = append(sorted, KeyValue{iter.Key(), iter.Value()})
}
slices.SortStableFunc(sorted, func(a, b KeyValue) int {
return compare(a.Key, b.Key)
})
return sorted
}
// compare compares two values of the same type. It returns -1, 0, 1
// according to whether a > b (1), a == b (0), or a < b (-1).
// If the types differ, it returns -1.
// See the comment on Sort for the comparison rules.
func compare(aVal, bVal reflect.Value) int {
aType, bType := aVal.Type(), bVal.Type()
if aType != bType {
return -1 // No good answer possible, but don't return 0: they're not equal.
}
switch aVal.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return cmp.Compare(aVal.Int(), bVal.Int())
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return cmp.Compare(aVal.Uint(), bVal.Uint())
case reflect.String:
return cmp.Compare(aVal.String(), bVal.String())
case reflect.Float32, reflect.Float64:
return cmp.Compare(aVal.Float(), bVal.Float())
case reflect.Complex64, reflect.Complex128:
a, b := aVal.Complex(), bVal.Complex()
if c := cmp.Compare(real(a), real(b)); c != 0 {
return c
}
return cmp.Compare(imag(a), imag(b))
case reflect.Bool:
a, b := aVal.Bool(), bVal.Bool()
switch {
case a == b:
return 0
case a:
return 1
default:
return -1
}
case reflect.Pointer, reflect.UnsafePointer:
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
case reflect.Chan:
if c, ok := nilCompare(aVal, bVal); ok {
return c
}
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
case reflect.Struct:
for i := 0; i < aVal.NumField(); i++ {
if c := compare(aVal.Field(i), bVal.Field(i)); c != 0 {
return c
}
}
return 0
case reflect.Array:
for i := 0; i < aVal.Len(); i++ {
if c := compare(aVal.Index(i), bVal.Index(i)); c != 0 {
return c
}
}
return 0
case reflect.Interface:
if c, ok := nilCompare(aVal, bVal); ok {
return c
}
c := compare(reflect.ValueOf(aVal.Elem().Type()), reflect.ValueOf(bVal.Elem().Type()))
if c != 0 {
return c
}
return compare(aVal.Elem(), bVal.Elem())
default:
// Certain types cannot appear as keys (maps, funcs, slices), but be explicit.
panic("bad type in compare: " + aType.String())
}
}
// nilCompare checks whether either value is nil. If not, the boolean is false.
// If either value is nil, the boolean is true and the integer is the comparison
// value. The comparison is defined to be 0 if both are nil, otherwise the one
// nil value compares low. Both arguments must represent a chan, func,
// interface, map, pointer, or slice.
func nilCompare(aVal, bVal reflect.Value) (int, bool) {
if aVal.IsNil() {
if bVal.IsNil() {
return 0, true
}
return -1, true
}
if bVal.IsNil() {
return 1, true
}
return 0, false
}
+774
View File
@@ -0,0 +1,774 @@
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gotext
import (
"errors"
"fmt"
"io"
"net/url"
"reflect"
"strings"
"sync"
"unicode"
"unicode/utf8"
)
// FuncMap is the type of the map defining the mapping from names to functions.
// Each function must have either a single return value, or two return values of
// which the second has type error. In that case, if the second (error)
// return value evaluates to non-nil during execution, execution terminates and
// Execute returns that error.
//
// Errors returned by Execute wrap the underlying error; call [errors.AsType] to
// unwrap them.
//
// When template execution invokes a function with an argument list, that list
// must be assignable to the function's parameter types. Functions meant to
// apply to arguments of arbitrary type can use parameters of type interface{} or
// of type [reflect.Value]. Similarly, functions meant to return a result of arbitrary
// type can return interface{} or [reflect.Value].
type FuncMap map[string]any
// builtins returns the FuncMap.
// It is not a global variable so the linker can dead code eliminate
// more when this isn't called. See golang.org/issue/36021.
// TODO: revert this back to a global map once golang.org/issue/2559 is fixed.
func builtins() FuncMap {
return FuncMap{
"and": and,
"call": emptyCall,
"html": HTMLEscaper,
"index": index,
"slice": slice,
"js": JSEscaper,
"len": length,
"not": not,
"or": or,
"print": fmt.Sprint,
"printf": fmt.Sprintf,
"println": fmt.Sprintln,
"urlquery": URLQueryEscaper,
// Comparisons
"eq": eq, // ==
"ge": ge, // >=
"gt": gt, // >
"le": le, // <=
"lt": lt, // <
"ne": ne, // !=
}
}
// builtinFuncs lazily computes & caches the builtinFuncs map.
var builtinFuncs = sync.OnceValue(func() map[string]reflect.Value {
funcMap := builtins()
m := make(map[string]reflect.Value, len(funcMap))
addValueFuncs(m, funcMap)
return m
})
// addValueFuncs adds to values the functions in funcs, converting them to reflect.Values.
func addValueFuncs(out map[string]reflect.Value, in FuncMap) {
for name, fn := range in {
if !goodName(name) {
panic(fmt.Errorf("function name %q is not a valid identifier", name))
}
v := reflect.ValueOf(fn)
if v.Kind() != reflect.Func {
panic("value for " + name + " not a function")
}
if err := goodFunc(name, v.Type()); err != nil {
panic(err)
}
out[name] = v
}
}
// addFuncs adds to values the functions in funcs. It does no checking of the input -
// call addValueFuncs first.
func addFuncs(out, in FuncMap) {
for name, fn := range in {
out[name] = fn
}
}
// goodFunc reports whether the function or method has the right result signature.
func goodFunc(name string, typ reflect.Type) error {
// We allow functions with 1 result or 2 results where the second is an error.
switch numOut := typ.NumOut(); {
case numOut == 1:
return nil
case numOut == 2 && typ.Out(1) == errorType:
return nil
case numOut == 2:
return fmt.Errorf("invalid function signature for %s: second return value should be error; is %s", name, typ.Out(1))
default:
return fmt.Errorf("function %s has %d return values; should be 1 or 2", name, typ.NumOut())
}
}
// goodName reports whether the function name is a valid identifier.
func goodName(name string) bool {
if name == "" {
return false
}
for i, r := range name {
switch {
case r == '_':
case i == 0 && !unicode.IsLetter(r):
return false
case !unicode.IsLetter(r) && !unicode.IsDigit(r):
return false
}
}
return true
}
// findFunction looks for a function in the template, and global map.
func findFunction(name string, tmpl *Template) (v reflect.Value, isBuiltin, ok bool) {
if tmpl != nil && tmpl.common != nil {
tmpl.muFuncs.RLock()
defer tmpl.muFuncs.RUnlock()
if fn := tmpl.execFuncs[name]; fn.IsValid() {
return fn, false, true
}
}
if fn := builtinFuncs()[name]; fn.IsValid() {
return fn, true, true
}
return reflect.Value{}, false, false
}
// prepareArg checks if value can be used as an argument of type argType, and
// converts an invalid value to appropriate zero if possible.
func prepareArg(value reflect.Value, argType reflect.Type) (reflect.Value, error) {
if !value.IsValid() {
if !canBeNil(argType) {
return reflect.Value{}, fmt.Errorf("value is nil; should be of type %s", argType)
}
value = reflect.Zero(argType)
}
if value.Type().AssignableTo(argType) {
return value, nil
}
if intLike(value.Kind()) && intLike(argType.Kind()) && value.Type().ConvertibleTo(argType) {
value = value.Convert(argType)
return value, nil
}
return reflect.Value{}, fmt.Errorf("value has type %s; should be %s", value.Type(), argType)
}
func intLike(typ reflect.Kind) bool {
switch typ {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return true
}
return false
}
// indexArg checks if a reflect.Value can be used as an index, and converts it to int if possible.
func indexArg(index reflect.Value, cap int) (int, error) {
var x int64
switch index.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
x = index.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
x = int64(index.Uint())
case reflect.Invalid:
return 0, fmt.Errorf("cannot index slice/array with nil")
default:
return 0, fmt.Errorf("cannot index slice/array with type %s", index.Type())
}
if x < 0 || int(x) < 0 || int(x) > cap {
return 0, fmt.Errorf("index out of range: %d", x)
}
return int(x), nil
}
// Indexing.
// index returns the result of indexing its first argument by the following
// arguments. Thus "index x 1 2 3" is, in Go syntax, x[1][2][3]. Each
// indexed item must be a map, slice, or array.
func index(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
item = indirectInterface(item)
if !item.IsValid() {
return reflect.Value{}, fmt.Errorf("index of untyped nil")
}
for _, index := range indexes {
index = indirectInterface(index)
var isNil bool
if item, isNil = indirect(item); isNil {
return reflect.Value{}, fmt.Errorf("index of nil pointer")
}
switch item.Kind() {
case reflect.Array, reflect.Slice, reflect.String:
x, err := indexArg(index, item.Len())
if err != nil {
return reflect.Value{}, err
}
item = item.Index(x)
case reflect.Map:
index, err := prepareArg(index, item.Type().Key())
if err != nil {
return reflect.Value{}, err
}
if x := item.MapIndex(index); x.IsValid() {
item = x
} else {
item = reflect.Zero(item.Type().Elem())
}
case reflect.Invalid:
// the loop holds invariant: item.IsValid()
panic("unreachable")
default:
return reflect.Value{}, fmt.Errorf("can't index item of type %s", item.Type())
}
}
return item, nil
}
// Slicing.
// slice returns the result of slicing its first argument by the remaining
// arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], while "slice x"
// is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" is x[1:2:3]. The first
// argument must be a string, slice, or array.
func slice(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
item = indirectInterface(item)
if !item.IsValid() {
return reflect.Value{}, fmt.Errorf("slice of untyped nil")
}
var isNil bool
if item, isNil = indirect(item); isNil {
return reflect.Value{}, fmt.Errorf("slice of nil pointer")
}
if len(indexes) > 3 {
return reflect.Value{}, fmt.Errorf("too many slice indexes: %d", len(indexes))
}
var cap int
switch item.Kind() {
case reflect.String:
if len(indexes) == 3 {
return reflect.Value{}, fmt.Errorf("cannot 3-index slice a string")
}
cap = item.Len()
case reflect.Array, reflect.Slice:
cap = item.Cap()
default:
return reflect.Value{}, fmt.Errorf("can't slice item of type %s", item.Type())
}
// set default values for cases item[:], item[i:].
idx := [3]int{0, item.Len()}
for i, index := range indexes {
x, err := indexArg(index, cap)
if err != nil {
return reflect.Value{}, err
}
idx[i] = x
}
// given item[i:j], make sure i <= j.
if idx[0] > idx[1] {
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[0], idx[1])
}
if len(indexes) < 3 {
return item.Slice(idx[0], idx[1]), nil
}
// given item[i:j:k], make sure i <= j <= k.
if idx[1] > idx[2] {
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[1], idx[2])
}
return item.Slice3(idx[0], idx[1], idx[2]), nil
}
// Length
// length returns the length of the item, with an error if it has no defined length.
func length(item reflect.Value) (int, error) {
item, isNil := indirect(item)
if isNil {
return 0, fmt.Errorf("len of nil pointer")
}
switch item.Kind() {
case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String:
return item.Len(), nil
}
return 0, fmt.Errorf("len of type %s", item.Type())
}
// Function invocation
func emptyCall(fn reflect.Value, args ...reflect.Value) reflect.Value {
panic("unreachable") // implemented as a special case in evalCall
}
// call returns the result of evaluating the first argument as a function.
// The function must return 1 result, or 2 results, the second of which is an error.
func call(name string, fn reflect.Value, args ...reflect.Value) (reflect.Value, error) {
fn = indirectInterface(fn)
if !fn.IsValid() {
return reflect.Value{}, fmt.Errorf("call of nil")
}
typ := fn.Type()
if typ.Kind() != reflect.Func {
return reflect.Value{}, fmt.Errorf("non-function %s of type %s", name, typ)
}
if err := goodFunc(name, typ); err != nil {
return reflect.Value{}, err
}
numIn := typ.NumIn()
var dddType reflect.Type
if typ.IsVariadic() {
if len(args) < numIn-1 {
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want at least %d", name, len(args), numIn-1)
}
dddType = typ.In(numIn - 1).Elem()
} else {
if len(args) != numIn {
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want %d", name, len(args), numIn)
}
}
argv := make([]reflect.Value, len(args))
for i, arg := range args {
arg = indirectInterface(arg)
// Compute the expected type. Clumsy because of variadics.
argType := dddType
if !typ.IsVariadic() || i < numIn-1 {
argType = typ.In(i)
}
var err error
if argv[i], err = prepareArg(arg, argType); err != nil {
return reflect.Value{}, fmt.Errorf("arg %d: %w", i, err)
}
}
return safeCall(fn, argv)
}
// safeCall runs fun.Call(args), and returns the resulting value and error, if
// any. If the call panics, the panic value is returned as an error.
func safeCall(fun reflect.Value, args []reflect.Value) (val reflect.Value, err error) {
defer func() {
if r := recover(); r != nil {
if e, ok := r.(error); ok {
err = e
} else {
err = fmt.Errorf("%v", r)
}
}
}()
ret := fun.Call(args)
if len(ret) == 2 && !ret[1].IsNil() {
return ret[0], ret[1].Interface().(error)
}
return ret[0], nil
}
// Boolean logic.
func truth(arg reflect.Value) bool {
t, _ := isTrue(indirectInterface(arg))
return t
}
// and computes the Boolean AND of its arguments, returning
// the first false argument it encounters, or the last argument.
func and(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
panic("unreachable") // implemented as a special case in evalCall
}
// or computes the Boolean OR of its arguments, returning
// the first true argument it encounters, or the last argument.
func or(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
panic("unreachable") // implemented as a special case in evalCall
}
// not returns the Boolean negation of its argument.
func not(arg reflect.Value) bool {
return !truth(arg)
}
// Comparison.
// TODO: Perhaps allow comparison between signed and unsigned integers.
var (
errBadComparisonType = errors.New("invalid type for comparison")
errNoComparison = errors.New("missing argument for comparison")
)
type kind int
const (
invalidKind kind = iota
boolKind
complexKind
intKind
floatKind
stringKind
uintKind
)
func basicKind(v reflect.Value) (kind, error) {
switch v.Kind() {
case reflect.Bool:
return boolKind, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return intKind, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return uintKind, nil
case reflect.Float32, reflect.Float64:
return floatKind, nil
case reflect.Complex64, reflect.Complex128:
return complexKind, nil
case reflect.String:
return stringKind, nil
}
return invalidKind, errBadComparisonType
}
// isNil returns true if v is the zero reflect.Value, or nil of its type.
func isNil(v reflect.Value) bool {
if !v.IsValid() {
return true
}
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return v.IsNil()
}
return false
}
// canCompare reports whether v1 and v2 are both the same kind, or one is nil.
// Called only when dealing with nillable types, or there's about to be an error.
func canCompare(v1, v2 reflect.Value) bool {
k1 := v1.Kind()
k2 := v2.Kind()
if k1 == k2 {
return true
}
// We know the type can be compared to nil.
return k1 == reflect.Invalid || k2 == reflect.Invalid
}
// eq evaluates the comparison a == b || a == c || ...
func eq(arg1 reflect.Value, arg2 ...reflect.Value) (bool, error) {
arg1 = indirectInterface(arg1)
if len(arg2) == 0 {
return false, errNoComparison
}
k1, _ := basicKind(arg1)
for _, arg := range arg2 {
arg = indirectInterface(arg)
k2, _ := basicKind(arg)
truth := false
if k1 != k2 {
// Special case: Can compare integer values regardless of type's sign.
switch {
case k1 == intKind && k2 == uintKind:
truth = arg1.Int() >= 0 && uint64(arg1.Int()) == arg.Uint()
case k1 == uintKind && k2 == intKind:
truth = arg.Int() >= 0 && arg1.Uint() == uint64(arg.Int())
default:
if arg1.IsValid() && arg.IsValid() {
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg.Type())
}
}
} else {
switch k1 {
case boolKind:
truth = arg1.Bool() == arg.Bool()
case complexKind:
truth = arg1.Complex() == arg.Complex()
case floatKind:
truth = arg1.Float() == arg.Float()
case intKind:
truth = arg1.Int() == arg.Int()
case stringKind:
truth = arg1.String() == arg.String()
case uintKind:
truth = arg1.Uint() == arg.Uint()
default:
if !canCompare(arg1, arg) {
return false, fmt.Errorf("non-comparable types %s: %v, %s: %v", arg1, arg1.Type(), arg.Type(), arg)
}
if isNil(arg1) || isNil(arg) {
truth = isNil(arg) == isNil(arg1)
} else {
if !arg.Type().Comparable() {
return false, fmt.Errorf("non-comparable type %s: %v", arg, arg.Type())
}
truth = arg1.Interface() == arg.Interface()
}
}
}
if truth {
return true, nil
}
}
return false, nil
}
// ne evaluates the comparison a != b.
func ne(arg1, arg2 reflect.Value) (bool, error) {
// != is the inverse of ==.
equal, err := eq(arg1, arg2)
return !equal, err
}
// lt evaluates the comparison a < b.
func lt(arg1, arg2 reflect.Value) (bool, error) {
arg1 = indirectInterface(arg1)
k1, err := basicKind(arg1)
if err != nil {
return false, err
}
arg2 = indirectInterface(arg2)
k2, err := basicKind(arg2)
if err != nil {
return false, err
}
truth := false
if k1 != k2 {
// Special case: Can compare integer values regardless of type's sign.
switch {
case k1 == intKind && k2 == uintKind:
truth = arg1.Int() < 0 || uint64(arg1.Int()) < arg2.Uint()
case k1 == uintKind && k2 == intKind:
truth = arg2.Int() >= 0 && arg1.Uint() < uint64(arg2.Int())
default:
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg2.Type())
}
} else {
switch k1 {
case boolKind, complexKind:
return false, errBadComparisonType
case floatKind:
truth = arg1.Float() < arg2.Float()
case intKind:
truth = arg1.Int() < arg2.Int()
case stringKind:
truth = arg1.String() < arg2.String()
case uintKind:
truth = arg1.Uint() < arg2.Uint()
default:
panic("invalid kind")
}
}
return truth, nil
}
// le evaluates the comparison <= b.
func le(arg1, arg2 reflect.Value) (bool, error) {
// <= is < or ==.
lessThan, err := lt(arg1, arg2)
if lessThan || err != nil {
return lessThan, err
}
return eq(arg1, arg2)
}
// gt evaluates the comparison a > b.
func gt(arg1, arg2 reflect.Value) (bool, error) {
// > is the inverse of <=.
lessOrEqual, err := le(arg1, arg2)
if err != nil {
return false, err
}
return !lessOrEqual, nil
}
// ge evaluates the comparison a >= b.
func ge(arg1, arg2 reflect.Value) (bool, error) {
// >= is the inverse of <.
lessThan, err := lt(arg1, arg2)
if err != nil {
return false, err
}
return !lessThan, nil
}
// HTML escaping.
var (
htmlQuot = []byte("&#34;") // shorter than "&quot;"
htmlApos = []byte("&#39;") // shorter than "&apos;" and apos was not in HTML until HTML5
htmlAmp = []byte("&amp;")
htmlLt = []byte("&lt;")
htmlGt = []byte("&gt;")
htmlNull = []byte("\uFFFD")
)
// HTMLEscape writes to w the escaped HTML equivalent of the plain text data b.
func HTMLEscape(w io.Writer, b []byte) {
last := 0
for i, c := range b {
var html []byte
switch c {
case '\000':
html = htmlNull
case '"':
html = htmlQuot
case '\'':
html = htmlApos
case '&':
html = htmlAmp
case '<':
html = htmlLt
case '>':
html = htmlGt
default:
continue
}
w.Write(b[last:i])
w.Write(html)
last = i + 1
}
w.Write(b[last:])
}
// HTMLEscapeString returns the escaped HTML equivalent of the plain text data s.
func HTMLEscapeString(s string) string {
// Avoid allocation if we can.
if !strings.ContainsAny(s, "'\"&<>\000") {
return s
}
var b strings.Builder
HTMLEscape(&b, []byte(s))
return b.String()
}
// HTMLEscaper returns the escaped HTML equivalent of the textual
// representation of its arguments.
func HTMLEscaper(args ...any) string {
return HTMLEscapeString(evalArgs(args))
}
// JavaScript escaping.
var (
jsLowUni = []byte(`\u00`)
hex = []byte("0123456789ABCDEF")
jsBackslash = []byte(`\\`)
jsApos = []byte(`\'`)
jsQuot = []byte(`\"`)
jsLt = []byte(`\u003C`)
jsGt = []byte(`\u003E`)
jsAmp = []byte(`\u0026`)
jsEq = []byte(`\u003D`)
)
// JSEscape writes to w the escaped JavaScript equivalent of the plain text data b.
func JSEscape(w io.Writer, b []byte) {
last := 0
for i := 0; i < len(b); i++ {
c := b[i]
if !jsIsSpecial(rune(c)) {
// fast path: nothing to do
continue
}
w.Write(b[last:i])
if c < utf8.RuneSelf {
// Quotes, slashes and angle brackets get quoted.
// Control characters get written as \u00XX.
switch c {
case '\\':
w.Write(jsBackslash)
case '\'':
w.Write(jsApos)
case '"':
w.Write(jsQuot)
case '<':
w.Write(jsLt)
case '>':
w.Write(jsGt)
case '&':
w.Write(jsAmp)
case '=':
w.Write(jsEq)
default:
w.Write(jsLowUni)
t, b := c>>4, c&0x0f
w.Write(hex[t : t+1])
w.Write(hex[b : b+1])
}
} else {
// Unicode rune.
r, size := utf8.DecodeRune(b[i:])
if unicode.IsPrint(r) {
w.Write(b[i : i+size])
} else {
fmt.Fprintf(w, "\\u%04X", r)
}
i += size - 1
}
last = i + 1
}
w.Write(b[last:])
}
// JSEscapeString returns the escaped JavaScript equivalent of the plain text data s.
func JSEscapeString(s string) string {
// Avoid allocation if we can.
if strings.IndexFunc(s, jsIsSpecial) < 0 {
return s
}
var b strings.Builder
JSEscape(&b, []byte(s))
return b.String()
}
func jsIsSpecial(r rune) bool {
switch r {
case '\\', '\'', '"', '<', '>', '&', '=':
return true
}
return r < ' ' || utf8.RuneSelf <= r
}
// JSEscaper returns the escaped JavaScript equivalent of the textual
// representation of its arguments.
func JSEscaper(args ...any) string {
return JSEscapeString(evalArgs(args))
}
// URLQueryEscaper returns the escaped value of the textual representation of
// its arguments in a form suitable for embedding in a URL query.
func URLQueryEscaper(args ...any) string {
return url.QueryEscape(evalArgs(args))
}
// evalArgs formats the list of arguments into a string. It is therefore equivalent to
//
// fmt.Sprint(args...)
//
// except that each argument is indirected (if a pointer), as required,
// using the same rules as the default string evaluation during template
// execution.
func evalArgs(args []any) string {
ok := false
var s string
// Fast path for simple common case.
if len(args) == 1 {
s, ok = args[0].(string)
}
if !ok {
for i, arg := range args {
a, ok := printableValue(reflect.ValueOf(arg))
if ok {
args[i] = a
} // else let fmt do its thing
}
s = fmt.Sprint(args...)
}
return s
}
+178
View File
@@ -0,0 +1,178 @@
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Helper functions to make constructing templates easier.
package gotext
import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
)
// Functions and methods to parse templates.
// Must is a helper that wraps a call to a function returning ([*Template], error)
// and panics if the error is non-nil. It is intended for use in variable
// initializations such as
//
// var t = template.Must(template.New("name").Parse("text"))
func Must(t *Template, err error) *Template {
if err != nil {
panic(err)
}
return t
}
// ParseFiles creates a new [Template] and parses the template definitions from
// the named files. The returned template's name will have the base name and
// parsed contents of the first file. There must be at least one file.
// If an error occurs, parsing stops and the returned *Template is nil.
//
// When parsing multiple files with the same name in different directories,
// the last one mentioned will be the one that results.
// For instance, ParseFiles("a/foo", "b/foo") stores "b/foo" as the template
// named "foo", while "a/foo" is unavailable.
func ParseFiles(filenames ...string) (*Template, error) {
return parseFiles(nil, readFileOS, filenames...)
}
// ParseFiles parses the named files and associates the resulting templates with
// t. If an error occurs, parsing stops and the returned template is nil;
// otherwise it is t. There must be at least one file.
// Since the templates created by ParseFiles are named by the base
// (see [filepath.Base]) names of the argument files, t should usually have the
// name of one of the (base) names of the files. If it does not, depending on
// t's contents before calling ParseFiles, t.Execute may fail. In that
// case use t.ExecuteTemplate to execute a valid template.
//
// When parsing multiple files with the same name in different directories,
// the last one mentioned will be the one that results.
func (t *Template) ParseFiles(filenames ...string) (*Template, error) {
t.init()
return parseFiles(t, readFileOS, filenames...)
}
// parseFiles is the helper for the method and function. If the argument
// template is nil, it is created from the first file.
func parseFiles(t *Template, readFile func(string) (string, []byte, error), filenames ...string) (*Template, error) {
if len(filenames) == 0 {
// Not really a problem, but be consistent.
return nil, fmt.Errorf("template: no files named in call to ParseFiles")
}
for _, filename := range filenames {
name, b, err := readFile(filename)
if err != nil {
return nil, err
}
s := string(b)
// First template becomes return value if not already defined,
// and we use that one for subsequent New calls to associate
// all the templates together. Also, if this file has the same name
// as t, this file becomes the contents of t, so
// t, err := New(name).Funcs(xxx).ParseFiles(name)
// works. Otherwise we create a new template associated with t.
var tmpl *Template
if t == nil {
t = New(name)
}
if name == t.Name() {
tmpl = t
} else {
tmpl = t.New(name)
}
_, err = tmpl.Parse(s)
if err != nil {
return nil, err
}
}
return t, nil
}
// ParseGlob creates a new [Template] and parses the template definitions from
// the files identified by the pattern. The files are matched according to the
// semantics of [filepath.Match], and the pattern must match at least one file.
// The returned template will have the [filepath.Base] name and (parsed)
// contents of the first file matched by the pattern. ParseGlob is equivalent to
// calling [ParseFiles] with the list of files matched by the pattern.
//
// When parsing multiple files with the same name in different directories,
// the last one mentioned will be the one that results.
func ParseGlob(pattern string) (*Template, error) {
return parseGlob(nil, pattern)
}
// ParseGlob parses the template definitions in the files identified by the
// pattern and associates the resulting templates with t. The files are matched
// according to the semantics of [filepath.Match], and the pattern must match at
// least one file. ParseGlob is equivalent to calling [Template.ParseFiles] with
// the list of files matched by the pattern.
//
// When parsing multiple files with the same name in different directories,
// the last one mentioned will be the one that results.
func (t *Template) ParseGlob(pattern string) (*Template, error) {
t.init()
return parseGlob(t, pattern)
}
// parseGlob is the implementation of the function and method ParseGlob.
func parseGlob(t *Template, pattern string) (*Template, error) {
filenames, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
if len(filenames) == 0 {
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
}
return parseFiles(t, readFileOS, filenames...)
}
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
// instead of the host operating system's file system.
// It accepts a list of glob patterns (see [path.Match]).
// (Note that most file names serve as glob patterns matching only themselves.)
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
return parseFS(nil, fsys, patterns)
}
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
// instead of the host operating system's file system.
// It accepts a list of glob patterns (see [path.Match]).
// (Note that most file names serve as glob patterns matching only themselves.)
func (t *Template) ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
t.init()
return parseFS(t, fsys, patterns)
}
func parseFS(t *Template, fsys fs.FS, patterns []string) (*Template, error) {
var filenames []string
for _, pattern := range patterns {
list, err := fs.Glob(fsys, pattern)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
}
filenames = append(filenames, list...)
}
return parseFiles(t, readFileFS(fsys), filenames...)
}
func readFileOS(file string) (name string, b []byte, err error) {
name = filepath.Base(file)
b, err = os.ReadFile(file)
return
}
func readFileFS(fsys fs.FS) func(string) (string, []byte, error) {
return func(file string) (name string, b []byte, err error) {
name = path.Base(file)
b, err = fs.ReadFile(fsys, file)
return
}
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This file contains the code to handle template options.
package gotext
import "strings"
// missingKeyAction defines how to respond to indexing a map with a key that is not present.
type missingKeyAction int
const (
mapInvalid missingKeyAction = iota // Return an invalid reflect.Value.
mapZeroValue // Return the zero value for the map element.
mapError // Error out
)
type option struct {
missingKey missingKeyAction
}
// Option sets options for the template. Options are described by
// strings, either a simple string or "key=value". There can be at
// most one equals sign in an option string. If the option string
// is unrecognized or otherwise invalid, Option panics.
//
// Known options:
//
// missingkey: Control the behavior during execution if a map is
// indexed with a key that is not present in the map.
//
// "missingkey=default" or "missingkey=invalid"
// The default behavior: Do nothing and continue execution.
// If printed, the result of the index operation is the string
// "<no value>".
// "missingkey=zero"
// The operation returns the zero value for the map type's element.
// "missingkey=error"
// Execution stops immediately with an error.
func (t *Template) Option(opt ...string) *Template {
t.init()
for _, s := range opt {
t.setOption(s)
}
return t
}
func (t *Template) setOption(opt string) {
if opt == "" {
panic("empty option string")
}
// key=value
if key, value, ok := strings.Cut(opt, "="); ok {
switch key {
case "missingkey":
switch value {
case "invalid", "default":
t.option.missingKey = mapInvalid
return
case "zero":
t.option.missingKey = mapZeroValue
return
case "error":
t.option.missingKey = mapError
return
}
}
}
panic("unrecognized option: " + opt)
}
@@ -0,0 +1,149 @@
--- a/exec.go 2026-07-10 01:31:35.188129862 +0200
+++ b/exec.go 2026-07-10 01:31:35.189129894 +0200
@@ -5,14 +5,17 @@
package gotext
import (
+ "context"
"errors"
"fmt"
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
"io"
"reflect"
"runtime"
"strings"
+ "sync/atomic"
"text/template/parse"
+
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
)
// maxExecDepth specifies the maximum stack depth of templates within
@@ -32,11 +35,13 @@
// template so that multiple executions of the same template
// can execute in parallel.
type state struct {
- tmpl *Template
- wr io.Writer
- node parse.Node // current node, for errors
- vars []variable // push-down stack of variable values.
- depth int // the height of the stack of executing templates.
+ tmpl *Template
+ ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
+ wr io.Writer
+ node parse.Node // current node, for errors
+ vars []variable // push-down stack of variable values.
+ depth int // the height of the stack of executing templates.
+ cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
}
// variable holds the dynamic value of a variable such as $, $x etc.
@@ -160,6 +165,14 @@
})
}
+// cancelError is the wrapper type used internally when execution is aborted
+// because the context is done. Like writeError, it is stripped in errRecover
+// so the caller receives the original ctx.Err(). It is not an implementation
+// of error, so it cannot escape from the package as an error value.
+type cancelError struct {
+ Err error // Original context error.
+}
+
// errRecover is the handler that turns panics into returns from the top
// level of Parse.
func errRecover(errp *error) {
@@ -170,6 +183,8 @@
panic(e)
case writeError:
*errp = err.Err // Strip the wrapper.
+ case cancelError:
+ *errp = err.Err // Strip the wrapper; return the context error.
case ExecError:
*errp = err // Keep the wrapper.
default:
@@ -186,11 +201,19 @@
// A template may be executed safely in parallel, although if parallel
// executions share a Writer the output may be interleaved.
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
+ return t.ExecuteTemplateContext(context.Background(), wr, name, data)
+}
+
+// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
+// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
+// execution completes. See [Template.ExecuteContext] for the cancellation
+// semantics.
+func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
tmpl := t.Lookup(name)
if tmpl == nil {
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
}
- return tmpl.Execute(wr, data)
+ return tmpl.ExecuteContext(ctx, wr, data)
}
// Execute applies a parsed template to the specified data object,
@@ -204,10 +227,27 @@
// If data is a [reflect.Value], the template applies to the concrete
// value that the reflect.Value holds, as in [fmt.Print].
func (t *Template) Execute(wr io.Writer, data any) error {
- return t.execute(wr, data)
+ return t.executeContext(context.Background(), wr, data)
}
-func (t *Template) execute(wr io.Writer, data any) (err error) {
+// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
+// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
+// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
+// completes.
+//
+// Cancellation is observed between node evaluations as the template is walked,
+// so long-running renders -- including tight or nested {{range}} loops that
+// write no output -- are aborted promptly. A template blocked inside a single
+// function call is not interrupted until that call returns. Partial results may
+// already have been written to wr.
+func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return t.executeContext(ctx, wr, data)
+}
+
+func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
defer errRecover(&err)
value, ok := data.(reflect.Value)
if !ok {
@@ -215,9 +255,20 @@
}
state := &state{
tmpl: t,
+ ctx: ctx,
wr: wr,
vars: []variable{{"$", value}},
}
+ // If the context can be canceled, watch it with a single context.AfterFunc
+ // callback that flips an atomic flag; walk polls that flag per node (a cheap
+ // monomorphic atomic load) instead of calling ctx.Err() every node.
+ // Contexts that can never be canceled (Background, TODO) have a nil Done
+ // channel, so the default Execute path installs nothing and pays nothing.
+ if ctx.Done() != nil {
+ state.cancelled = new(atomic.Bool)
+ stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
+ defer stop()
+ }
if t.Tree == nil || t.Root == nil {
state.errorf("%q is an incomplete or empty template", t.Name())
}
@@ -260,6 +311,12 @@
// generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) {
s.at(node)
+ // Abort if the context has been canceled or its deadline has passed. The
+ // flag is set by the watcher installed in executeContext; observing it here
+ // interrupts any template shape, including loops that write no output.
+ if s.cancelled != nil && s.cancelled.Load() {
+ panic(cancelError{s.ctx.Err()})
+ }
switch node := node.(type) {
case *parse.ActionNode:
// Do not pop variables so they persist until next end.
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gotext
import (
"maps"
"reflect"
"sync"
"text/template/parse"
)
// common holds the information shared by related templates.
type common struct {
tmpl map[string]*Template // Map from name to defined templates.
muTmpl sync.RWMutex // protects tmpl
option option
// We use two maps, one for parsing and one for execution.
// This separation makes the API cleaner since it doesn't
// expose reflection to the client.
muFuncs sync.RWMutex // protects parseFuncs and execFuncs
parseFuncs FuncMap
execFuncs map[string]reflect.Value
}
// Template is the representation of a parsed template. The *parse.Tree
// field is exported only for use by [html/template] and should be treated
// as unexported by all other clients.
type Template struct {
name string
*parse.Tree
*common
leftDelim string
rightDelim string
}
// New allocates a new, undefined template with the given name.
func New(name string) *Template {
t := &Template{
name: name,
}
t.init()
return t
}
// Name returns the name of the template.
func (t *Template) Name() string {
return t.name
}
// New allocates a new, undefined template associated with the given one and with the same
// delimiters. The association, which is transitive, allows one template to
// invoke another with a {{template}} action.
//
// Because associated templates share underlying data, template construction
// cannot be done safely in parallel. Once the templates are constructed, they
// can be executed in parallel.
func (t *Template) New(name string) *Template {
t.init()
nt := &Template{
name: name,
common: t.common,
leftDelim: t.leftDelim,
rightDelim: t.rightDelim,
}
return nt
}
// init guarantees that t has a valid common structure.
func (t *Template) init() {
if t.common == nil {
c := new(common)
c.tmpl = make(map[string]*Template)
c.parseFuncs = make(FuncMap)
c.execFuncs = make(map[string]reflect.Value)
t.common = c
}
}
// Clone returns a duplicate of the template, including all associated
// templates. The actual representation is not copied, but the name space of
// associated templates is, so further calls to [Template.Parse] in the copy will add
// templates to the copy but not to the original. Clone can be used to prepare
// common templates and use them with variant definitions for other templates
// by adding the variants after the clone is made.
func (t *Template) Clone() (*Template, error) {
nt := t.copy(nil)
nt.init()
if t.common == nil {
return nt, nil
}
nt.option = t.option
t.muTmpl.RLock()
defer t.muTmpl.RUnlock()
for k, v := range t.tmpl {
if k == t.name {
nt.tmpl[t.name] = nt
continue
}
// The associated templates share nt's common structure.
tmpl := v.copy(nt.common)
nt.tmpl[k] = tmpl
}
t.muFuncs.RLock()
defer t.muFuncs.RUnlock()
maps.Copy(nt.parseFuncs, t.parseFuncs)
maps.Copy(nt.execFuncs, t.execFuncs)
return nt, nil
}
// copy returns a shallow copy of t, with common set to the argument.
func (t *Template) copy(c *common) *Template {
return &Template{
name: t.name,
Tree: t.Tree,
common: c,
leftDelim: t.leftDelim,
rightDelim: t.rightDelim,
}
}
// AddParseTree associates the argument parse tree with the template t, giving
// it the specified name. If the template has not been defined, this tree becomes
// its definition. If it has been defined and already has that name, the existing
// definition is replaced; otherwise a new template is created, defined, and returned.
func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error) {
t.init()
t.muTmpl.Lock()
defer t.muTmpl.Unlock()
nt := t
if name != t.name {
nt = t.New(name)
}
// Even if nt == t, we need to install it in the common.tmpl map.
if t.associate(nt, tree) || nt.Tree == nil {
nt.Tree = tree
}
return nt, nil
}
// Templates returns a slice of defined templates associated with t.
func (t *Template) Templates() []*Template {
if t.common == nil {
return nil
}
// Return a slice so we don't expose the map.
t.muTmpl.RLock()
defer t.muTmpl.RUnlock()
m := make([]*Template, 0, len(t.tmpl))
for _, v := range t.tmpl {
m = append(m, v)
}
return m
}
// Delims sets the action delimiters to the specified strings, to be used in
// subsequent calls to [Template.Parse], [Template.ParseFiles], or [Template.ParseGlob]. Nested template
// definitions will inherit the settings. An empty delimiter stands for the
// corresponding default: {{ or }}.
// The return value is the template, so calls can be chained.
func (t *Template) Delims(left, right string) *Template {
t.init()
t.leftDelim = left
t.rightDelim = right
return t
}
// Funcs adds the elements of the argument map to the template's function map.
// It must be called before the template is parsed.
// It panics if a value in the map is not a function with appropriate return
// type or if the name cannot be used syntactically as a function in a template.
// It is legal to overwrite elements of the map. The return value is the template,
// so calls can be chained.
func (t *Template) Funcs(funcMap FuncMap) *Template {
t.init()
t.muFuncs.Lock()
defer t.muFuncs.Unlock()
addValueFuncs(t.execFuncs, funcMap)
addFuncs(t.parseFuncs, funcMap)
return t
}
// Lookup returns the template with the given name that is associated with t.
// It returns nil if there is no such template or the template has no definition.
func (t *Template) Lookup(name string) *Template {
if t.common == nil {
return nil
}
t.muTmpl.RLock()
defer t.muTmpl.RUnlock()
return t.tmpl[name]
}
// Parse parses text as a template body for t.
// Named template definitions ({{define ...}} or {{block ...}} statements) in text
// define additional templates associated with t and are removed from the
// definition of t itself.
//
// Templates can be redefined in successive calls to Parse.
// A template definition with a body containing only white space and comments
// is considered empty and will not replace an existing template's body.
// This allows using Parse to add new named template definitions without
// overwriting the main template body.
func (t *Template) Parse(text string) (*Template, error) {
t.init()
t.muFuncs.RLock()
trees, err := parse.Parse(t.name, text, t.leftDelim, t.rightDelim, t.parseFuncs, builtins())
t.muFuncs.RUnlock()
if err != nil {
return nil, err
}
// Add the newly parsed trees, including the one for t, into our common structure.
for name, tree := range trees {
if _, err := t.AddParseTree(name, tree); err != nil {
return nil, err
}
}
return t, nil
}
// associate installs the new template into the group of templates associated
// with t. The two are already known to share the common structure.
// The boolean return value reports whether to store this tree as t.Tree.
func (t *Template) associate(new *Template, tree *parse.Tree) bool {
if new.common != t.common {
panic("internal error: associate not common")
}
if old := t.tmpl[new.name]; old != nil && parse.IsEmptyTree(tree.Root) && old.Tree != nil {
// If a template by that name exists,
// don't replace it with an empty template.
return false
}
t.tmpl[new.name] = new
return true
}
+325 -16
View File
@@ -2,6 +2,7 @@
package user
import (
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
@@ -40,6 +41,7 @@ const (
DefaultUserPasswordBcryptCost = 10
DefaultAccessCacheEnabled = false
DefaultAccessCacheReloadInterval = 87 * time.Second
DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept
)
var (
@@ -72,6 +74,9 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
if config.AccessCacheReloadInterval <= 0 {
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
}
if config.ExpiredMagicLinkReapInterval <= 0 {
config.ExpiredMagicLinkReapInterval = DefaultExpiredMagicLinkReapInterval
}
manager := &Manager{
config: config,
db: d,
@@ -91,6 +96,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
}
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
go manager.asyncExpiredMagicLinkReapLoop(manager.config.ExpiredMagicLinkReapInterval)
return manager, nil
}
@@ -128,6 +134,25 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
}
}
// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and
// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is
// already enforced on read, so this is housekeeping only; it replaces the old in-memory
// expireLoop that lived in mail.Sender.
func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-a.quit:
return
case <-ticker.C:
if err := a.deleteExpiredMagicLinks(); err != nil {
log.Tag(tag).Err(err).Warn("Reaping expired magic links failed")
}
}
}
}
// Authenticate checks username and password and returns a User if correct, and the user has not been
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
// the password is correct or incorrect.
@@ -494,6 +519,19 @@ func (a *Manager) UserByID(id string) (*User, error) {
return a.readUser(rows)
}
// UserByEmailOrUsername resolves an identifier to a single user, trying it first as a primary
// email address and then as a username. A verified, owned email takes precedence over a
// freely-chosen username, so a look-alike username cannot shadow the email's real owner. Returns
// ErrUserNotFound if neither matches.
func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
if userID, err := a.UserIDByPrimaryEmail(identifier); err == nil {
if u, err := a.UserByID(userID); err == nil {
return u, nil
}
}
return a.User(identifier)
}
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
func (a *Manager) userByToken(token string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
@@ -630,7 +668,7 @@ func (a *Manager) maybeHashPassword(password string, hashed bool) (string, error
}
return password, nil
}
return hashPassword(password, a.config.BcryptCost)
return HashPassword(password, a.config.BcryptCost)
}
// Authorize returns nil if the given user has access to the given topic using the desired
@@ -639,6 +677,13 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error {
if user != nil && user.Role == RoleAdmin {
return nil // Admin can do everything
}
// A user always has full access to their own sync topic, which the apps use
// to sync subscriptions/settings across devices. Without this, an
// auth-default-access of "deny-all" locks the user out of their own sync
// topic (no ACL entry is created for it at user creation). See #733.
if user != nil && user.SyncTopic != "" && subtle.ConstantTimeCompare([]byte(topic), []byte(user.SyncTopic)) == 1 {
return nil
}
username := Everyone
if user != nil {
username = user.Name
@@ -896,7 +941,9 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
// Reservations returns all user-owned topics, and the associated everyone-access
func (a *Manager) Reservations(username string) ([]Reservation, error) {
return a.reservationsTx(a.db.ReadOnly(), username)
// Read from the primary, not a replica: this backs GET /account, which the web app refetches
// immediately after a sync event. Replication lag would otherwise show stale data.
return a.reservationsTx(a.db, username)
}
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
@@ -1159,7 +1206,8 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
// Tokens returns all existing tokens for the user with the given user ID
func (a *Manager) Tokens(userID string) ([]*Token, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)
// Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectTokens, userID)
if err != nil {
return nil, err
}
@@ -1372,7 +1420,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
// PhoneNumbers returns all phone numbers for the user with the given user ID
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)
// Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID)
if err != nil {
return nil, err
}
@@ -1420,14 +1469,17 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
return phoneNumber, nil
}
// Emails returns all verified email addresses for the user with the given user ID
func (a *Manager) Emails(userID string) ([]string, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
// Emails returns all verified email addresses for the user with the given user ID, each carrying
// whether it is the primary (recovery) address. Because the primary flag is included, callers that
// need it (e.g. the account view) do not need a separate PrimaryEmail call.
func (a *Manager) Emails(userID string) (Emails, error) {
// Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectEmails, userID)
if err != nil {
return nil, err
}
defer rows.Close()
emails := make([]string, 0)
emails := make(Emails, 0)
for {
email, err := a.readEmail(rows)
if errors.Is(err, ErrEmailNotFound) {
@@ -1451,23 +1503,280 @@ func (a *Manager) AddEmail(userID, email string) error {
return nil
}
// RemoveEmail deletes a verified email address from the user with the given user ID
// RemoveEmail deletes a verified email address from the user with the given user ID.
// Removing the primary email leaves the account with no primary -- there is deliberately
// no auto-promotion of another verified address; the user is nudged to pick a new one.
func (a *Manager) RemoveEmail(userID, email string) error {
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
return err
}
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
var email string
// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if
// the user has not designated one.
func (a *Manager) PrimaryEmail(userID string) (string, error) {
var email sql.NullString
// Primary read: backs GET /account (read-your-writes after a sync event).
err := a.db.QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
} else if err != nil {
return "", err
}
return email.String, nil
}
// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address
// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the
// password-reset request flow to resolve an email identifier to a single account.
func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) {
var userID string
err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrUserNotFound
} else if err != nil {
return "", err
}
return userID, nil
}
// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with
// an outstanding email-verification magic link.
func (a *Manager) PendingEmails(userID string) ([]string, error) {
// Primary read: backs GET /account (read-your-writes after a sync event).
rows, err := a.db.Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID)
if err != nil {
return nil, err
}
defer rows.Close()
emails := make([]string, 0)
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, err
}
emails = append(emails, email)
}
if err := rows.Err(); err != nil {
return nil, err
}
return emails, nil
}
// SetPrimaryEmail marks a verified email address as the user's primary (recovery) email,
// clearing any previous primary in the same transaction. Returns ErrEmailNotFound if the
// address is not verified on the account, or ErrEmailPrimaryElsewhere if it is already the
// primary email on another account (enforced by the global partial unique index).
func (a *Manager) SetPrimaryEmail(userID, email string) error {
return db.ExecTx(a.db, func(tx *sql.Tx) error {
if _, err := tx.Exec(a.queries.updateEmailClearPrimary, userID); err != nil {
return err
}
res, err := tx.Exec(a.queries.updateEmailSetPrimary, userID, email)
if err != nil {
if isUniqueConstraintError(err) {
return ErrEmailPrimaryElsewhere
}
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrEmailNotFound // Address not verified on this account
}
return nil
})
}
// AddMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, replacing
// any existing link in the same scope), and returns the RAW token for use in the emailed link.
// Only the hash is persisted; the raw token is never stored. email is the address being verified
// for email_verify, and "" for password_reset.
//
// The scope replaced is, for email_verify, the (user_id, email) pair (one pending verification per
// address); for password_reset, the user_id (one active reset per account). The replace-delete and
// the insert run in one transaction so a re-request atomically supersedes the old token.
func (a *Manager) AddMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) {
token := generateLinkToken()
now := time.Now()
m := &MagicLink{
TokenHash: hashToken(token),
Kind: kind,
UserID: userID,
Email: email,
Expires: now.Add(ttl).Unix(),
Created: now.Unix(),
}
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
switch m.Kind {
case MagicLinkKindEmailVerify:
if _, err := tx.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil {
return err
}
case MagicLinkKindPasswordReset:
if _, err := tx.Exec(a.queries.deleteMagicLinkResetPassword, string(MagicLinkKindPasswordReset), m.UserID); err != nil {
return err
}
default:
return ErrInvalidArgument
}
if _, err := tx.Exec(a.queries.insertMagicLink, m.TokenHash, string(m.Kind), m.UserID, nullString(m.Email), m.Expires, m.Created); err != nil {
return err
}
return nil
})
if err != nil {
return "", err
}
return token, nil
}
// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash.
func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) {
return a.MagicLinkByHash(hashToken(rawToken))
}
// MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning
// ErrMagicLinkNotFound if none exists. Callers must assert the returned Kind matches the flow
// they serve and check Expires themselves.
func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) {
var m MagicLink
var kind string
var email sql.NullString
err := a.db.ReadOnly().QueryRow(a.queries.selectMagicLinkByHash, tokenHash).Scan(&m.TokenHash, &kind, &m.UserID, &email, &m.Expires, &m.Created)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrMagicLinkNotFound
} else if err != nil {
return nil, err
}
m.Kind = MagicLinkKind(kind)
m.Email = email.String
return &m, nil
}
// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume).
// Used to enforce single use after a reset is performed (email verification deletes the row
// inside VerifyEmail's transaction).
func (a *Manager) DeleteMagicLinkByToken(rawToken string) error {
_, err := a.db.Exec(a.queries.deleteMagicLinkByHash, hashToken(rawToken))
return err
}
// DeleteEmailVerification removes any pending email verification for (userID, email). Used when
// an unverified (pending) address is cancelled/deleted from the account.
func (a *Manager) DeleteEmailVerification(userID, email string) error {
_, err := a.db.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), userID, email)
return err
}
// VerifyEmail consumes an email-verification magic link, identified by its raw token: after
// validating the token (kind + expiry), it deletes the link, adds the address to the user's
// verified emails, and -- if the user has no primary email yet and the address is not already
// primary on another account -- promotes the new address to primary. All mutations run in one
// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned
// users never get a primary (the recovery email is meaningless for them -- they can't reset).
// Returns the consumed link.
func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) {
tokenHash := hashToken(rawToken)
m, err := a.MagicLinkByHash(tokenHash)
if err != nil {
return nil, err
}
if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires {
return nil, ErrMagicLinkNotFound
}
err = db.ExecTx(a.db, func(tx *sql.Tx) error {
// Single use: delete the link, then add the (idempotent) verified address
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil {
return err
}
if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil {
return err
}
// Promote to primary only if the user has none yet and the address is globally free.
// We check with SELECTs rather than catching a unique violation, because Postgres aborts
// the whole transaction on any constraint error (which would undo the verified-email add).
var primary sql.NullString
err := tx.QueryRow(a.queries.selectPrimaryEmail, m.UserID).Scan(&primary)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if primary.String != "" {
return nil // User already has a primary -- leave it
}
// If the address is already another account's primary, leave it a verified secondary here
var ownerUserID string
if err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&ownerUserID); err == nil {
return nil // Address is primary elsewhere -> not promoted
} else if !errors.Is(err, sql.ErrNoRows) {
return err // Real query error
}
// Address is globally free -> promote it to this user's primary
if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return m, nil
}
// ResetPassword consumes a password-reset magic link, identified by its raw token: after
// validating the token (kind + expiry), it sets the user's password and deletes the link in one
// transaction. Existing access tokens are intentionally left valid (only the password changes).
// Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token.
func (a *Manager) ResetPassword(rawToken, newPassword string) error {
m, err := a.MagicLinkByHash(hashToken(rawToken))
if err != nil {
return err
}
if m.Kind != MagicLinkKindPasswordReset || time.Now().Unix() > m.Expires {
return ErrMagicLinkNotFound
}
u, err := a.UserByID(m.UserID)
if err != nil {
return err
}
if u.Provisioned {
return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset
}
hash, err := HashPassword(newPassword, a.config.BcryptCost)
if err != nil {
return err
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
if err := a.changePasswordHashTx(tx, u.Name, hash); err != nil {
return err
}
if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, m.TokenHash); err != nil {
return err
}
return nil
})
}
// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced
// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop.
func (a *Manager) deleteExpiredMagicLinks() error {
_, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix())
return err
}
func (a *Manager) readEmail(rows *sql.Rows) (*Email, error) {
var address string
var primary bool
if !rows.Next() {
return "", ErrEmailNotFound
return nil, ErrEmailNotFound
}
if err := rows.Scan(&email); err != nil {
return "", err
if err := rows.Scan(&address, &primary); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return "", err
return nil, err
}
return email, nil
return &Email{Address: address, Primary: primary}, nil
}
// ChangeBilling updates a user's billing fields
+29 -3
View File
@@ -217,9 +217,23 @@ const (
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
// Email queries
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
postgresSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = $1 ORDER BY email`
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING`
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
postgresSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = $1 AND is_primary`
postgresSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = $1 AND is_primary`
postgresUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = TRUE WHERE user_id = $1 AND email = $2`
postgresUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = FALSE WHERE user_id = $1 AND is_primary`
// Magic link queries (email verification + password reset)
postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)`
postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1`
postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1`
postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2 AND email = $3`
postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2`
postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = $1 AND user_id = $2 ORDER BY email`
postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1`
// Billing queries
postgresUpdateBillingQuery = `
@@ -306,7 +320,19 @@ var postgresQueries = queries{
deletePhoneNumber: postgresDeletePhoneNumberQuery,
selectEmails: postgresSelectEmailsQuery,
insertEmail: postgresInsertEmailQuery,
insertEmailIgnore: postgresInsertEmailIgnoreQuery,
deleteEmail: postgresDeleteEmailQuery,
selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
insertMagicLink: postgresInsertMagicLinkQuery,
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
selectPendingEmails: postgresSelectPendingEmailsQuery,
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
updateBilling: postgresUpdateBillingQuery,
}
+43 -1
View File
@@ -75,8 +75,21 @@ const (
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
email TEXT NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (user_id, email)
);
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
CREATE TABLE IF NOT EXISTS user_magic_link (
token_hash TEXT NOT NULL,
kind TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
email TEXT,
expires BIGINT NOT NULL,
created BIGINT NOT NULL,
PRIMARY KEY (token_hash)
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
@@ -89,7 +102,7 @@ const (
// Schema table management queries for Postgres
const (
postgresCurrentSchemaVersion = 7
postgresCurrentSchemaVersion = 8
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
)
@@ -102,11 +115,30 @@ const (
PRIMARY KEY (user_id, email)
);
`
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
// No backfill -- existing verified emails stay non-primary.
postgresMigrate7To8UpdateQueries = `
ALTER TABLE user_email ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT FALSE;
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary;
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary;
CREATE TABLE IF NOT EXISTS user_magic_link (
token_hash TEXT NOT NULL,
kind TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
email TEXT,
expires BIGINT NOT NULL,
created BIGINT NOT NULL,
PRIMARY KEY (token_hash)
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
)
var postgresMigrations = map[int]func(db *sql.DB) error{
6: postgresMigrateFrom6,
7: postgresMigrateFrom7,
}
func setupPostgres(db *sql.DB) error {
@@ -141,6 +173,16 @@ func postgresMigrateFrom6(db *sql.DB) error {
return nil
}
func postgresMigrateFrom7(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
}
func setupNewPostgres(db *sql.DB) error {
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
return err
+29 -3
View File
@@ -214,9 +214,23 @@ const (
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
// Email queries
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
sqliteSelectEmailsQuery = `SELECT email, is_primary FROM user_email WHERE user_id = ? ORDER BY email`
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING`
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
sqliteSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = ? AND is_primary = 1`
sqliteSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1`
sqliteUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = 1 WHERE user_id = ? AND email = ?`
sqliteUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = 0 WHERE user_id = ? AND is_primary = 1`
// Magic link queries (email verification + password reset)
sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)`
sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?`
sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?`
sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ? AND email = ?`
sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ?`
sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = ? AND user_id = ? ORDER BY email`
sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?`
// Billing queries
sqliteUpdateBillingQuery = `
@@ -302,7 +316,19 @@ var sqliteQueries = queries{
deletePhoneNumber: sqliteDeletePhoneNumberQuery,
selectEmails: sqliteSelectEmailsQuery,
insertEmail: sqliteInsertEmailQuery,
insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
deleteEmail: sqliteDeleteEmailQuery,
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
insertMagicLink: sqliteInsertMagicLinkQuery,
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
selectPendingEmails: sqliteSelectPendingEmailsQuery,
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
updateBilling: sqliteUpdateBillingQuery,
}
+49 -1
View File
@@ -88,9 +88,23 @@ const (
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL,
email TEXT NOT NULL,
is_primary INT NOT NULL DEFAULT (0),
PRIMARY KEY (user_id, email),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1;
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1;
CREATE TABLE IF NOT EXISTS user_magic_link (
token_hash TEXT NOT NULL,
kind TEXT NOT NULL,
user_id TEXT NOT NULL,
email TEXT,
expires INT NOT NULL,
created INT NOT NULL,
PRIMARY KEY (token_hash),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
@@ -107,7 +121,7 @@ const (
// Schema version table management for SQLite
const (
sqliteCurrentSchemaVersion = 7
sqliteCurrentSchemaVersion = 8
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@@ -236,6 +250,26 @@ const (
);
`
// 7 -> 8: primary (recovery) email + magic-link table for verification/reset.
// No backfill -- existing verified emails stay non-primary, so the ALTER cannot
// conflict and no old notification address becomes a recovery channel.
sqliteMigrate7To8UpdateQueries = `
ALTER TABLE user_email ADD COLUMN is_primary INT NOT NULL DEFAULT (0);
CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1;
CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1;
CREATE TABLE IF NOT EXISTS user_magic_link (
token_hash TEXT NOT NULL,
kind TEXT NOT NULL,
user_id TEXT NOT NULL,
email TEXT,
expires INT NOT NULL,
created INT NOT NULL,
PRIMARY KEY (token_hash),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
`
// 5 -> 6
sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
@@ -339,6 +373,7 @@ var (
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
7: sqliteMigrateFrom7,
}
)
@@ -493,3 +528,16 @@ func sqliteMigrateFrom6(sqlDB *sql.DB) error {
return nil
})
}
func sqliteMigrateFrom7(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 7 to 8")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
})
}
+488 -2
View File
@@ -2,6 +2,7 @@ package user
import (
"database/sql"
"errors"
"fmt"
"net/netip"
"path/filepath"
@@ -129,6 +130,13 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
require.Nil(t, a.Authorize(ben, "announcements", PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite))
// User has full access to their own sync topic, even under deny-all,
// but not to another user's sync topic (#733)
require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionRead))
require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionWrite))
require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionRead))
require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionWrite))
// User john should have
// "deny" to mytopic_deny*,
// "ro" to mytopic_ro*,
@@ -1149,7 +1157,7 @@ func TestUser_EmailAddListRemove(t *testing.T) {
emails, err := a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, 1, len(emails))
require.Equal(t, "phil@example.com", emails[0])
require.Equal(t, "phil@example.com", emails[0].Address)
require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
emails, err = a.Emails(phil.ID)
@@ -2693,7 +2701,7 @@ func TestStoreEmails(t *testing.T) {
emails, err = manager.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 1)
require.Equal(t, "phil2@example.com", emails[0])
require.Equal(t, "phil2@example.com", emails[0].Address)
})
}
@@ -2827,3 +2835,481 @@ func TestStoreOtherAccessCount(t *testing.T) {
require.Equal(t, 2, count) // ben's owner entry + everyone entry
})
}
// addVerifyLink stores an email-verification magic link and returns the raw token so the test
// can "click" it via VerifyEmail.
func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string {
raw, err := a.AddMagicLink(MagicLinkKindEmailVerify, userID, email, ttl)
require.Nil(t, err)
return raw
}
func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
// Before verifying: pending, not yet verified, no primary
pending, err := a.PendingEmails(phil.ID)
require.Nil(t, err)
require.Equal(t, []string{"phil@example.com"}, pending)
emails, err := a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, 0, len(emails))
primary, err := a.PrimaryEmail(phil.ID)
require.Nil(t, err)
require.Equal(t, "", primary)
// Verify: the first verified email auto-becomes primary
m, err := a.VerifyEmail(raw)
require.Nil(t, err)
require.Equal(t, "phil@example.com", m.Email)
emails, err = a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, []string{"phil@example.com"}, emails.Strings())
primary, err = a.PrimaryEmail(phil.ID)
require.Nil(t, err)
require.Equal(t, "phil@example.com", primary)
pending, err = a.PendingEmails(phil.ID)
require.Nil(t, err)
require.Equal(t, 0, len(pending))
// Reset-by-email lookup resolves to the account
userID, err := a.UserIDByPrimaryEmail("phil@example.com")
require.Nil(t, err)
require.Equal(t, phil.ID, userID)
})
}
func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour)
_, err = a.VerifyEmail(raw1)
require.Nil(t, err)
raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour)
_, err = a.VerifyEmail(raw2)
require.Nil(t, err)
// Both verified, but primary is still the first
emails, err := a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, []string{"first@example.com", "second@example.com"}, emails.Strings())
primary, err := a.PrimaryEmail(phil.ID)
require.Nil(t, err)
require.Equal(t, "first@example.com", primary)
})
}
func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")
require.Nil(t, err)
// phil verifies shared@ first -> becomes his primary
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
primary, err := a.PrimaryEmail(phil.ID)
require.Nil(t, err)
require.Equal(t, "shared@example.com", primary)
// ben verifies the same address -> allowed as secondary, but NOT his primary
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
emails, err := a.Emails(ben.ID)
require.Nil(t, err)
require.Equal(t, []string{"shared@example.com"}, emails.Strings())
primary, err = a.PrimaryEmail(ben.ID)
require.Nil(t, err)
require.Equal(t, "", primary)
// Explicitly promoting ben's copy to primary collides with phil's
require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere)
// ...and phil keeps his primary (the failed promotion rolled back ben's clear)
primary, err = a.PrimaryEmail(phil.ID)
require.Nil(t, err)
require.Equal(t, "shared@example.com", primary)
})
}
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound)
})
}
func TestUser_MagicLink_Expired(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute)
_, err = a.VerifyEmail(raw)
require.ErrorIs(t, err, ErrMagicLinkNotFound)
// Nothing got verified
emails, err := a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, 0, len(emails))
})
}
func TestUser_MagicLink_SingleUse(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
_, err = a.VerifyEmail(raw)
require.Nil(t, err)
// Second click: token already consumed
_, err = a.VerifyEmail(raw)
require.ErrorIs(t, err, ErrMagicLinkNotFound)
})
}
func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour)
// Only one pending row remains; the old token no longer works
pending, err := a.PendingEmails(phil.ID)
require.Nil(t, err)
require.Equal(t, []string{"phil@example.com"}, pending)
_, err = a.MagicLinkByToken(raw1)
require.ErrorIs(t, err, ErrMagicLinkNotFound)
m, err := a.MagicLinkByToken(raw2)
require.Nil(t, err)
require.Equal(t, "phil@example.com", m.Email)
})
}
func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
require.Nil(t, err)
m, err := a.MagicLinkByToken(raw)
require.Nil(t, err)
require.Equal(t, MagicLinkKindPasswordReset, m.Kind)
require.Equal(t, phil.ID, m.UserID)
require.Equal(t, "", m.Email) // reset rows carry no email
// Reset rows do not appear as pending emails
pending, err := a.PendingEmails(phil.ID)
require.Nil(t, err)
require.Equal(t, 0, len(pending))
// New request replaces the old token
raw2, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
require.Nil(t, err)
_, err = a.MagicLinkByToken(raw)
require.ErrorIs(t, err, ErrMagicLinkNotFound)
// Single use: deleting consumes it
require.Nil(t, a.DeleteMagicLinkByToken(raw2))
_, err = a.MagicLinkByToken(raw2)
require.ErrorIs(t, err, ErrMagicLinkNotFound)
})
}
func TestUser_MagicLink_Reaper(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour)
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour)
require.Nil(t, a.deleteExpiredMagicLinks())
_, err = a.MagicLinkByToken(expired)
require.ErrorIs(t, err, ErrMagicLinkNotFound)
m, err := a.MagicLinkByToken(valid)
require.Nil(t, err)
require.Equal(t, "valid@example.com", m.Email)
})
}
// TestUser_MagicLink_ReaperLoop proves the background reap goroutine actually runs on its
// configured interval: an expired link inserted into a manager with a tiny reap interval is
// deleted without anyone calling deleteExpiredMagicLinks directly. Mirrors the loop-coverage
// pattern of TestAccessCacheReloadInterval_PicksUpExternalWrite.
func TestUser_MagicLink_ReaperLoop(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManagerFromConfig(t, newManager, &Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
ExpiredMagicLinkReapInterval: 25 * time.Millisecond,
})
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour)
valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour)
// The background loop (not a direct call) must reap the expired link within a few intervals
require.Eventually(t, func() bool {
_, err := a.MagicLinkByToken(expired)
return errors.Is(err, ErrMagicLinkNotFound)
}, 2*time.Second, 10*time.Millisecond, "reaper loop never deleted the expired magic link")
// The unexpired link must survive
m, err := a.MagicLinkByToken(valid)
require.Nil(t, err)
require.Equal(t, "valid@example.com", m.Email)
})
}
func TestUser_MagicLink_ResetPassword(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
require.Nil(t, err)
// Old password works before reset
_, err = a.Authenticate("phil", "oldpass")
require.Nil(t, err)
require.Nil(t, a.ResetPassword(raw, "newpass"))
// New password works, old does not
_, err = a.Authenticate("phil", "newpass")
require.Nil(t, err)
_, err = a.Authenticate("phil", "oldpass")
require.ErrorIs(t, err, ErrUnauthenticated)
// Token is single-use
require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound)
})
}
func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
// An email-verification token must not be usable for password reset...
verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour)
require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound)
// ...and a reset token must not be usable for email verification
resetToken, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour)
require.Nil(t, err)
_, err = a.VerifyEmail(resetToken)
require.ErrorIs(t, err, ErrMagicLinkNotFound)
// Old password unchanged
_, err = a.Authenticate("phil", "oldpass")
require.Nil(t, err)
})
}
func TestUser_MagicLink_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManagerFromConfig(t, newManager, &Config{
DefaultAccess: PermissionDenyAll,
ProvisionEnabled: true,
Users: []*User{
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
},
})
prov, err := a.User("prov")
require.Nil(t, err)
// A provisioned user's first verified email becomes their primary, just like a regular user
// (the primary is also the X-Email: yes target; password reset stays blocked separately).
_, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour))
require.Nil(t, err)
emails, err := a.Emails(prov.ID)
require.Nil(t, err)
require.Equal(t, []string{"prov@example.com"}, emails.Strings())
primary, err := a.PrimaryEmail(prov.ID)
require.Nil(t, err)
require.Equal(t, "prov@example.com", primary)
})
}
func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// Provisioned users come from the config file (ProvisionEnabled), not AddUser
a := newTestManagerFromConfig(t, newManager, &Config{
DefaultAccess: PermissionDenyAll,
ProvisionEnabled: true,
Users: []*User{
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
},
})
prov, err := a.User("prov")
require.Nil(t, err)
require.True(t, prov.Provisioned)
// A reset token can be created, but consuming it must be rejected for a provisioned user
// (their password comes from the config file, like change-pass).
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour)
require.Nil(t, err)
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange)
})
}
func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute)
require.Nil(t, err)
require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound)
_, err = a.Authenticate("phil", "oldpass")
require.Nil(t, err)
})
}
func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
_, err := a.UserIDByPrimaryEmail("ghost@example.com")
require.ErrorIs(t, err, ErrUserNotFound)
})
}
func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
u, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(u.ID, "a@example.com"))
require.Nil(t, a.AddEmail(u.ID, "b@example.com"))
require.Nil(t, a.SetPrimaryEmail(u.ID, "b@example.com"))
// Emails() carries the primary flag, so a separate PrimaryEmail() call is unnecessary.
emails, err := a.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 2)
require.Equal(t, "a@example.com", emails[0].Address) // ORDER BY email
require.False(t, emails[0].Primary)
require.Equal(t, "b@example.com", emails[1].Address)
require.True(t, emails[1].Primary)
// Helper methods for the address-only callers
require.Equal(t, []string{"a@example.com", "b@example.com"}, emails.Strings())
require.True(t, emails.Contains("a@example.com"))
require.False(t, emails.Contains("c@example.com"))
})
}
// openReplicaTestSQLite opens a fresh SQLite database file with the user schema applied.
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
require.Nil(t, err)
require.Nil(t, setupSQLite(d))
return d
}
// TestManager_AccountReadsUsePrimary verifies that the per-user reads backing the GET /account
// endpoint read from the primary, not from a read replica. The sync event ("something changed")
// is published immediately after a write, so a replica that lags would make the account view
// stale right after the user changes it. These reads must therefore be read-your-writes consistent.
//
// The test wires up a primary and a deliberately-empty replica (simulating replication lag),
// forces the replica healthy so ReadOnly() would route to it, writes everything to the primary,
// and asserts the reads still observe the fresh primary data.
func TestManager_AccountReadsUsePrimary(t *testing.T) {
dir := t.TempDir()
primaryDB := openReplicaTestSQLite(t, filepath.Join(dir, "primary.db"))
replicaDB := openReplicaTestSQLite(t, filepath.Join(dir, "replica.db")) // intentionally left empty
pool := db.New(&db.Host{DB: primaryDB}, []*db.Host{{DB: replicaDB}})
pool.MarkReplicasHealthyForTest() // force ReadOnly() to route to the stale replica
a, err := newManager(pool, sqliteQueries, &Config{BcryptCost: bcrypt.MinCost})
require.Nil(t, err)
t.Cleanup(func() { a.Close() })
// All writes below go to the primary; the replica stays empty.
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
u, err := a.User("phil")
require.Nil(t, err)
_, err = a.CreateToken(u.ID, "test token", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
require.Nil(t, err)
require.Nil(t, a.AddReservation("phil", "mytopic", PermissionDenyAll, 10))
require.Nil(t, a.AddPhoneNumber(u.ID, "+12223334444"))
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, a.SetPrimaryEmail(u.ID, "phil@example.com"))
_, err = a.AddMagicLink(MagicLinkKindEmailVerify, u.ID, "pending@example.com", time.Hour)
require.Nil(t, err)
// Each read must observe the just-written primary data, NOT the empty replica.
tokens, err := a.Tokens(u.ID)
require.Nil(t, err)
require.Len(t, tokens, 1)
reservations, err := a.Reservations("phil")
require.Nil(t, err)
require.Len(t, reservations, 1)
phoneNumbers, err := a.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Len(t, phoneNumbers, 1)
emails, err := a.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 1)
require.Equal(t, "phil@example.com", emails[0].Address)
require.True(t, emails[0].Primary)
primaryEmail, err := a.PrimaryEmail(u.ID)
require.Nil(t, err)
require.Equal(t, "phil@example.com", primaryEmail)
pendingEmails, err := a.PendingEmails(u.ID)
require.Nil(t, err)
require.Len(t, pendingEmails, 1)
}
+85 -15
View File
@@ -73,9 +73,32 @@ type TokenUpdate struct {
LastOrigin netip.Addr
}
// MagicLinkKind discriminates the two link-token flows stored in the user_magic_link table.
type MagicLinkKind string
// Magic link kinds
const (
MagicLinkKindEmailVerify MagicLinkKind = "email_verify"
MagicLinkKindPasswordReset MagicLinkKind = "password_reset"
)
// MagicLink is a pending, single-use link token -- either an email verification or a
// password reset, distinguished by Kind. The raw token travels in the emailed link;
// only its TokenHash (hex SHA-256) is persisted.
type MagicLink struct {
TokenHash string
Kind MagicLinkKind
UserID string
Email string // Address being verified for email_verify; empty (NULL) for password_reset
Expires int64
Created int64
}
// Prefs represents a user's configuration settings
type Prefs struct {
Language *string `json:"language,omitempty"`
DateFormat *string `json:"date_format,omitempty"`
TimeFormat *string `json:"time_format,omitempty"`
Notification *NotificationPrefs `json:"notification,omitempty"`
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
}
@@ -161,6 +184,36 @@ type Reservation struct {
Everyone Permission
}
// Email is a verified email address on a user account, along with whether it is the user's
// designated primary (recovery) address.
type Email struct {
Address string
Primary bool
}
// Emails is a list of verified email addresses for a user.
type Emails []*Email
// Strings returns just the address strings, in the same order. It is a convenience for callers
// that only care about the addresses and not the primary flag.
func (e Emails) Strings() []string {
addresses := make([]string, len(e))
for i, email := range e {
addresses[i] = email.Address
}
return addresses
}
// Contains reports whether the given address is in the list.
func (e Emails) Contains(address string) bool {
for _, email := range e {
if email.Address == address {
return true
}
}
return false
}
// Permission represents a read or write permission to a topic
type Permission uint8
@@ -245,18 +298,19 @@ const (
// Config holds the configuration for the user Manager
type Config struct {
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
DatabaseURL string // Database connection string (PostgreSQL)
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
DatabaseURL string // Database connection string (PostgreSQL)
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
ExpiredMagicLinkReapInterval time.Duration // Interval for sweeping expired email-verify/password-reset links
}
// Error constants used by the package
@@ -275,6 +329,8 @@ var (
ErrPhoneNumberExists = errors.New("phone number already exists")
ErrEmailNotFound = errors.New("email not found")
ErrEmailExists = errors.New("email already exists")
ErrEmailPrimaryElsewhere = errors.New("email is the primary email on another account")
ErrMagicLinkNotFound = errors.New("magic link not found")
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
)
@@ -350,9 +406,23 @@ type queries struct {
deletePhoneNumber string
// Email queries
selectEmails string
insertEmail string
deleteEmail string
selectEmails string
insertEmail string
insertEmailIgnore string // Idempotent insert (ON CONFLICT DO NOTHING) used inside VerifyEmail
deleteEmail string
selectPrimaryEmail string
selectUserIDByPrimary string
updateEmailSetPrimary string
updateEmailClearPrimary string
// Magic link queries (email verification + password reset)
insertMagicLink string
selectMagicLinkByHash string
deleteMagicLinkByHash string
deleteMagicLinkEmailVerify string // Delete pending email_verify rows for (user_id, email)
deleteMagicLinkResetPassword string // Delete the active password_reset row for user_id
selectPendingEmails string // Pending (unverified) email addresses for a user
deleteExpiredMagicLinks string
// Billing queries
updateBilling string
+22 -4
View File
@@ -1,7 +1,9 @@
package user
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"regexp"
"strings"
@@ -9,6 +11,11 @@ import (
"heckel.io/ntfy/v2/util"
)
// linkTokenLength is the length of a raw magic-link token. At 48 base62 characters
// it carries ~285 bits of entropy, well above the ~256-bit target, so the tokens
// need no brute-force cap -- just expiry and single-use.
const linkTokenLength = 48
var (
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
@@ -67,12 +74,23 @@ func GenerateToken() string {
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
}
// HashPassword hashes the given password using bcrypt with the configured cost
func HashPassword(password string) (string, error) {
return hashPassword(password, DefaultUserPasswordBcryptCost)
// generateLinkToken returns a fresh high-entropy raw token for a magic link
// (email verification or password reset). The raw token is carried in the emailed
// link; only its hashToken digest is persisted.
func generateLinkToken() string {
return util.RandomString(linkTokenLength)
}
func hashPassword(password string, cost int) (string, error) {
// hashToken returns the hex-encoded SHA-256 digest of a raw magic-link token.
// Tokens are stored hashed so a database read cannot yield working links; a high-entropy
// token makes a fast (unsalted) hash sufficient, unlike a password.
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// HashPassword hashes the given password using bcrypt with the given cost
func HashPassword(password string, cost int) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", err
+6 -6
View File
@@ -176,7 +176,7 @@ func TestHashPassword(t *testing.T) {
password := "test-password-123"
// Hash the password
hash, err := HashPassword(password)
hash, err := HashPassword(password, DefaultUserPasswordBcryptCost)
require.Nil(t, err)
require.NotEmpty(t, hash)
@@ -187,12 +187,12 @@ func TestHashPassword(t *testing.T) {
require.True(t, strings.HasPrefix(hash, "$2a$"))
// Hash the same password again - should produce different hash
hash2, err := HashPassword(password)
hash2, err := HashPassword(password, DefaultUserPasswordBcryptCost)
require.Nil(t, err)
require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)")
// Empty password should still work
emptyHash, err := HashPassword("")
emptyHash, err := HashPassword("", DefaultUserPasswordBcryptCost)
require.Nil(t, err)
require.NotEmpty(t, emptyHash)
require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost))
@@ -202,15 +202,15 @@ func TestHashPassword_WithCost(t *testing.T) {
password := "test-password"
// Test with different costs
hash4, err := hashPassword(password, 4)
hash4, err := HashPassword(password, 4)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash4, "$2a$04$"))
hash10, err := hashPassword(password, 10)
hash10, err := HashPassword(password, 10)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash10, "$2a$10$"))
hash12, err := hashPassword(password, 12)
hash12, err := HashPassword(password, 12)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash12, "$2a$12$"))
+3 -3
View File
@@ -5,7 +5,6 @@ import (
"path/filepath"
"reflect"
"strings"
"text/template"
"time"
)
@@ -21,8 +20,9 @@ const (
//
// tpl := template.New("foo").Funcs(sprig.FuncMap()))
//
// TxtFuncMap returns a 'text/template'.FuncMap
func TxtFuncMap() template.FuncMap {
// TxtFuncMap returns the function map as a plain map[string]any, assignable to any text/template or
// html/template FuncMap (including ntfy's vendored internal/template).
func TxtFuncMap() map[string]any {
return map[string]any{
// Date functions
"ago": dateAgo,
-34
View File
@@ -1,34 +0,0 @@
package util
import (
"errors"
"io"
"time"
)
// ErrWriteTimeout is returned when a write timed out
var ErrWriteTimeout = errors.New("write operation failed due to timeout")
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
type TimeoutWriter struct {
writer io.Writer
timeout time.Duration
start time.Time
}
// NewTimeoutWriter creates a new TimeoutWriter
func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
return &TimeoutWriter{
writer: w,
timeout: timeout,
start: time.Now(),
}
}
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
if time.Since(tw.start) > tw.timeout {
return 0, ErrWriteTimeout
}
return tw.writer.Write(p)
}
+36 -10
View File
@@ -2,20 +2,19 @@ package util
import (
"bytes"
crand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"math/rand"
"net/netip"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -30,8 +29,6 @@ const (
)
var (
random = rand.New(rand.NewSource(time.Now().UnixNano()))
randomMutex = sync.Mutex{}
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
errInvalidPriority = errors.New("invalid priority")
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
@@ -144,14 +141,33 @@ func RandomLowerStringPrefix(prefix string, length int) string {
return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)
}
// randomStringPrefixWithCharset builds a random string from charset using crypto/rand.
// We use rejection sampling (dropping the few highest byte values that would skew the
// distribution) so every character is uniformly distributed -- important because these
// strings back security tokens (access tokens, magic-link tokens, IDs), not just labels.
func randomStringPrefixWithCharset(prefix string, length int, charset string) string {
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
defer randomMutex.Unlock()
b := make([]byte, length-len(prefix))
for i := range b {
b[i] = charset[random.Intn(len(charset))]
n := length - len(prefix)
if n <= 0 {
return prefix[:length]
}
return prefix + string(b)
result := make([]byte, n)
limit := 256 - (256 % len(charset)) // reject byte values >= limit to avoid modulo bias
buf := make([]byte, n)
for i := 0; i < n; {
if _, err := crand.Read(buf); err != nil {
panic("crypto/rand failed: " + err.Error()) // Should never happen on a sane system
}
for _, c := range buf {
if i >= n {
break
}
if int(c) < limit {
result[i] = charset[int(c)%len(charset)]
i++
}
}
}
return prefix + string(result)
}
// ValidRandomString returns true if the given string matches the format created by RandomString
@@ -343,6 +359,16 @@ func MaybeMarshalJSON(v any) string {
return string(jsonBytes)
}
// EncodeJSON writes the JSON encoding of v to w, without escaping HTML-significant
// characters (<, >, &). Unlike the standard library's default, ntfy does not embed its
// JSON responses in HTML, so escaping these characters only makes the raw output harder
// to read (see #1511).
func EncodeJSON(w io.Writer, v any) error {
encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(false)
return encoder.Encode(v)
}
// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
//
+32
View File
@@ -1,6 +1,7 @@
package util
import (
"bytes"
"errors"
"io"
"net/netip"
@@ -25,6 +26,30 @@ func TestRandomString(t *testing.T) {
require.NotEqual(t, s1, s2)
}
// TestRandomString_CSPRNG guards the crypto/rand-backed generator: every character must come
// from the expected charset (rejection sampling correctness) and a large batch must be unique
// (no clock-seeded PRNG collapsing to a predictable stream).
func TestRandomString_CSPRNG(t *testing.T) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
seen := make(map[string]bool)
charCounts := make(map[rune]int)
for i := 0; i < 5000; i++ {
s := RandomString(48)
require.Equal(t, 48, len(s))
require.False(t, seen[s], "duplicate random string generated")
seen[s] = true
for _, c := range s {
require.Contains(t, charset, string(c))
charCounts[c]++
}
}
// Every charset character should appear at least once across 5000*48 draws; a heavily
// biased or broken generator would leave gaps.
for _, c := range charset {
require.Greater(t, charCounts[c], 0, "character %q never appeared", string(c))
}
}
func TestFileExists(t *testing.T) {
filename := filepath.Join(t.TempDir(), "somefile.txt")
require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600))
@@ -275,3 +300,10 @@ func TestMaybeMarshalJSON(t *testing.T) {
require.Equal(t, `"`+strings.Repeat("x", 4999), MaybeMarshalJSON(strings.Repeat("x", 6000)))
}
func TestEncodeJSON(t *testing.T) {
// HTML-significant characters (<, >, &) must NOT be escaped, see #1511
var buf bytes.Buffer
require.Nil(t, EncodeJSON(&buf, map[string]string{"message": "<b>a&b</b>"}))
require.Equal(t, `{"message":"<b>a&b</b>"}`+"\n", buf.String())
}
+2 -1
View File
@@ -1 +1,2 @@
src/app/emojis.js
src/app/emojis.js
src/app/emojisMapped.js
+1
View File
@@ -2,3 +2,4 @@ build/
dist/
public/static/langs/
src/app/emojis.js
src/app/emojisMapped.js
+86 -2
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@@ -14,7 +14,7 @@
<meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
<link rel="mask-icon" href="/static/images/ntfy-mask.svg" color="#317f6f" />
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
@@ -44,6 +44,85 @@
<!-- PWA -->
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Splash: painted before the JS bundle loads, faded out by the app once ready (see
src/app/splash.js). Background matches MUI's grey[100]/grey[900] for a seamless handoff. -->
<style>
html {
background-color: #f5f5f5;
}
html.dark {
background-color: #212121;
}
#splash {
position: fixed;
inset: 0;
z-index: 200000;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
opacity: 1;
/* Background fade = the app fading in once the logo is gone (see src/app/splash.js). */
transition: opacity 0.1s ease-out;
}
html.dark #splash {
background-color: #212121;
}
#splash.splash-hidden {
opacity: 0;
pointer-events: none;
}
#splash img {
width: 112px;
height: 112px;
/* Gently pulse while loading; src/app/splash.js stops this and fades the logo out. */
animation: splash-pulse 1.4s ease-in-out infinite;
}
@keyframes splash-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
#splash {
transition: none;
}
#splash img {
animation: none;
}
}
</style>
<!-- Decide light/dark before first paint from the "prefcache" blob (written by PrefCache.jsx) --
avoids the async-IndexedDB theme flash. Keep the key in sync with PrefCache.jsx. -->
<script>
(function () {
try {
var cache = JSON.parse(localStorage.getItem("prefcache"));
var theme = cache && cache.theme;
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var dark = theme === "dark" || ((!theme || theme === "system") && prefersDark);
if (dark) {
document.documentElement.classList.add("dark");
}
} catch (e) {
/* localStorage/matchMedia/JSON unavailable -- fall back to the default light splash */
}
})();
</script>
</head>
<body>
<noscript>
@@ -51,6 +130,11 @@
<a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
subscribe.
</noscript>
<!-- Static splash, removed by src/app/splash.js once ready. Logo is a same-origin SVG (precached by the SW). -->
<div id="splash" aria-hidden="true">
<img src="/static/images/ntfy-splash.svg" alt="" />
</div>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/index.jsx"></script>
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"checkJs": false
},
"include": ["src"],
"exclude": ["node_modules", "build"]
}
+1539 -1011
View File
File diff suppressed because it is too large Load Diff
+15 -12
View File
@@ -8,30 +8,32 @@
"serve": "vite preview",
"format": "prettier . --write",
"format:check": "prettier . --check",
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2",
"@mui/material": "latest",
"dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.1.2",
"dexie": "^4.4.4",
"dexie-react-hooks": "^4.4.0",
"i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^3.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^4.0.0",
"react": "latest",
"react-dom": "latest",
"react-i18next": "^11.16.2",
"react-infinite-scroll-component": "^6.1.0",
"react-infinite-scroll-component": "^7.2.1",
"react-remark": "^2.1.0",
"react-router-dom": "^6.30.4",
"stylis": "^4.3.0",
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^8.41.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.8.0",
@@ -39,9 +41,10 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8",
"vite": "^6.4.2",
"vite-plugin-pwa": "^1.0.0"
"prettier": "^3.9.4",
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^4.1.9"
},
"browserslist": {
"production": [
+12 -4
View File
@@ -5,7 +5,9 @@
font-family: "Roboto";
font-style: normal;
font-weight: 300;
src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2");
src:
local(""),
url("../fonts/roboto-v29-latin-300.woff2") format("woff2");
}
/* roboto-regular - latin */
@@ -13,7 +15,9 @@
font-family: "Roboto";
font-style: normal;
font-weight: 400;
src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2");
src:
local(""),
url("../fonts/roboto-v29-latin-regular.woff2") format("woff2");
}
/* roboto-500 - latin */
@@ -21,7 +25,9 @@
font-family: "Roboto";
font-style: normal;
font-weight: 500;
src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2");
src:
local(""),
url("../fonts/roboto-v29-latin-500.woff2") format("woff2");
}
/* roboto-700 - latin */
@@ -29,5 +35,7 @@
font-family: "Roboto";
font-style: normal;
font-weight: 700;
src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2");
src:
local(""),
url("../fonts/roboto-v29-latin-700.woff2") format("woff2");
}

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="#9a9a9a">
<g transform="translate(-51.451771,-87.327048)">
<path d="m 59.291677,93.677052 c -3.579993,0 -6.646873,2.817003 -6.646873,6.398338 v 0.003 l 0.03508,27.86677 -0.899113,6.63475 12.226096,-3.24797 H 94.40052 c 3.579985,0 6.64687,-2.82079 6.64687,-6.40216 v -24.85449 c 0,-3.580312 -3.065184,-6.39668 -6.643822,-6.398338 h -0.0031 z m 0,4.516205 h 35.108844 0.0031 c 1.257851,0.0013 2.12767,0.916373 2.12767,1.882133 v 24.85442 c 0,0.9666 -0.871353,1.88213 -2.13072,1.88213 H 63.344139 l -6.211425,1.87679 0.0633,-0.36604 -0.03431,-28.2473 c 0,-0.966516 0.870609,-1.882133 2.129956,-1.882133 z" />
<g transform="matrix(2.1452134,0,0,2.5503116,-71.247407,-178.388)">
<path d="m 62.57046,116.77004 v -1.31201 l 3.280018,-1.45904 q 0.158346,-0.0679 0.305381,-0.1018 0.158346,-0.0452 0.282761,-0.0679 0.135725,-0.0113 0.271449,-0.0226 v -0.0905 q -0.135724,-0.0113 -0.271449,-0.0452 -0.124415,-0.0226 -0.282761,-0.0566 -0.147035,-0.0452 -0.305381,-0.1131 l -3.280018,-1.45904 v -1.32332 l 5.067063,2.31863 v 1.4138 z" />
<path d="m 62.308594,110.31055 v 1.90234 l 3.4375,1.5293 c 0.0073,0.003 0.0142,0.005 0.02148,0.008 -0.0073,0.003 -0.0142,0.005 -0.02148,0.008 l -3.4375,1.5293 v 1.89258 l 0.371093,-0.16992 5.220704,-2.39063 v -1.75 z m 0.52539,0.8164 4.541016,2.08008 v 1.07617 l -4.541016,2.07813 v -0.73242 l 3.119141,-1.38868 0.0039,-0.002 c 0.09141,-0.0389 0.178343,-0.0676 0.257813,-0.0859 h 0.0059 l 0.0078,-0.002 c 0.09483,-0.0271 0.176055,-0.0474 0.246093,-0.0606 l 0.498047,-0.041 v -0.57422 l -0.240234,-0.0195 c -0.07606,-0.006 -0.153294,-0.0198 -0.230469,-0.0391 l -0.0078,-0.002 -0.0078,-0.002 c -0.07608,-0.0138 -0.16556,-0.0318 -0.263672,-0.0527 -0.08398,-0.0262 -0.172736,-0.058 -0.265625,-0.0977 l -0.0039,-0.002 -3.119141,-1.38868 z" />
</g>
<g transform="matrix(2.1388566,0,0,2.4558588,-69.745456,-170.93962)">
<path d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z" />
<path d="m 68.908203,117.49219 v 0.26172 1.54101 h 5.955078 v -1.80273 z m 0.525391,0.52344 h 4.904297 v 0.7539 h -4.904297 z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1 -1
View File
@@ -361,7 +361,7 @@
"account_upgrade_dialog_tier_price_per_month": "شهر",
"prefs_appearance_theme_title": "السمة",
"subscribe_dialog_subscribe_use_another_background_info": "لن يتم استلام الاشعارات من الخوادم الخارجية عندما يكون تطبيق الويب مغلقاً",
"prefs_appearance_theme_system": "النظام (الافتراضي)",
"prefs_system_default": "النظام الافتراضي",
"prefs_notifications_min_priority_low_and_higher": "أولوية منخفضة وأعلى",
"prefs_notifications_min_priority_default_and_higher": "الأولوية الافتراضية وما فوقها",
"prefs_notifications_min_priority_high_and_higher": "أولوية عالية وأعلى"
+59 -5
View File
@@ -243,8 +243,8 @@
"account_basics_password_description": "Промяна на паролата на профила",
"account_basics_tier_title": "Вид на профила",
"account_basics_tier_admin": "Администратор",
"account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)",
"account_basics_tier_admin_suffix_no_tier": "(без ниво)",
"account_basics_tier_admin_suffix_with_tier": "с {{tier}} ниво",
"account_basics_tier_admin_suffix_no_tier": "без ниво",
"account_basics_tier_free": "безплатен",
"account_basics_tier_basic": "базов",
"account_basics_tier_change_button": "Променяне",
@@ -373,7 +373,7 @@
"prefs_reservations_table_everyone_read_write": "Всички могат да публикуват и да се абонират",
"reservation_delete_dialog_submit_button": "Премахване на резервирането",
"account_tokens_description": "Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.",
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>",
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>.",
"prefs_reservations_dialog_description": "Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
"reservation_delete_dialog_action_keep_title": "Пазене на съобщения и прикачени файлове",
"reservation_delete_dialog_action_keep_description": "Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.",
@@ -400,10 +400,64 @@
"prefs_notifications_web_push_title": "Известия във фонов режим",
"prefs_notifications_web_push_enabled_description": "Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)",
"prefs_appearance_theme_title": "Цветова тема",
"prefs_appearance_theme_system": "Системна (подразбирана)",
"prefs_system_default": "Системна подразбирана",
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
"action_bar_unmute_notifications": "Включване звука на известията",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Кодът за защита от външна система не може да бъде променян или премахван",
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван"
"account_basics_cannot_edit_or_delete_provisioned_user": "Потребител от външна система не може да бъде променян или премахван",
"common_close": "Затваряне",
"common_refresh": "Презареждане",
"email_verify_progress_title": "Проверяване адреса на ел. поща…",
"email_verify_success_title": "Адресът на ел. поща е проверен",
"email_verify_success_description": "Адресът на ел. поща е проверен е добавен към профила.",
"email_verify_error_title": "Проверката не е успешна",
"email_verify_error_description": "Препратката за проверка е повредена или с изтекла валидност. Вземете друга от настройките на профила.",
"email_verify_button_account": "Към профила",
"version_update_available_title": "Налично е ново издание",
"version_update_available_description": "Сървърът на ntfy е обновен. Презаредете страницата.",
"signup_form_email": "Адрес на ел. поща (по желание, с цел възстановяване)",
"login_link_forgot_password": "Забравена парола",
"reset_password_request_title": "Нулиране на парола",
"reset_password_request_description": "Въведете потребителско име или адрес на ел. поща. Ако такъв профил съществува по пощата ще бъде изпратена препратка, от която да смените паролата си.",
"reset_password_request_primary_required": "Работи само в случай, че сте задали и потвърдили основен адрес на ел. поща.",
"reset_password_request_identifier_label": "Потребителско име или ел. поща",
"reset_password_request_button_submit": "Изпращане",
"reset_password_sent_title": "Проверете входящата поща",
"reset_password_sent_description": "Ако такъв профил съществува на пощата е изпратена препратка, от която да смените паролата си.",
"reset_password_back_to_login": "Към вписване",
"reset_password_disabled": "Смяната на парола е изключена",
"reset_password_title": "Задаване на нова парола",
"reset_password_form_password": "Нова парола",
"reset_password_form_confirm": "Потвърждаване на новата парола",
"reset_password_form_button_submit": "Задаване на парола",
"reset_password_form_error_invalid": "Препратката е неприемлива или изтекла. Поискайте нова.",
"reset_password_success_title": "Паролата е променена",
"reset_password_success_description": "Паролата е сменена. Можете да се впишете с нея.",
"action_bar_reload": "Презареждане на приложението",
"account_basics_emails_title": "Адреси на ел. поща",
"account_basics_emails_description": "За известия и промяна на праролата",
"account_basics_emails_no_emails_yet": "Не са зададени ел. адреси",
"account_basics_emails_copied_to_clipboard": "Адресът на ел. поща е копиран",
"account_basics_emails_chip_actions_primary": "Основен адрес, използван като ел. адрес по подразбиране. Щракнете за действия.",
"account_basics_emails_chip_actions_verified": "Може да бъде използван за известия. Щракнете за действия.",
"account_basics_emails_chip_actions_unverified": "Непотвърден адрес, проверете входящата поща. Щракнете за действия.",
"account_basics_emails_unverified": "непотвърден",
"account_basics_emails_set_primary": "Задаване като основен",
"account_basics_emails_delete": "Премахване",
"account_basics_emails_resend": "Повторно изпращане на писмо за потвърждаване",
"account_basics_emails_resent": "Писмото е изпратено, проверете входящата поща",
"account_basics_emails_primary_elsewhere": "Този адрес на ел. поща е използван като основен адрес на друг профил",
"account_basics_emails_no_recovery_warning": "Добавете адрес на ел. поща, за да можете да възстановите профила ако забравите паролата.",
"account_basics_emails_no_primary_warning": "Добавете основен адрес на ел. поща, за да можете да възстановите профила ако забравите паролата.",
"account_basics_emails_dialog_title": "Добавяне адреси на ел. поща",
"account_basics_emails_dialog_description": "Въведете адрес на ел. поща, който да бъде добавен към профила. Ще бъде изпратена препратка за потвърждение.",
"account_basics_emails_dialog_email_label": "Адрес на ел. поща",
"account_basics_emails_dialog_email_placeholder": "напр. user@example.com",
"account_basics_emails_dialog_verify_button": "Препратка за потвърждаване",
"account_basics_emails_dialog_check_inbox": "Проверете входящата поща и посетете препратката за потвърждаване. Докато не го направите адресът ще стои непотвърден.",
"account_basics_tier_provisioned": "Потребител от външна система",
"account_usage_emails_none": "Профилът не може да изпраща известия по ел. поща",
"prefs_users_dialog_base_url_invalid": "Неприемлив формат на адрес. Трябва да започва с http:// или https://",
"prefs_users_dialog_base_url_exists": "Потребител за този адрес на услуга вече съществува"
}
+1 -1
View File
@@ -398,7 +398,7 @@
"prefs_notifications_web_push_disabled_description": "Oznámení jsou přijímána, když je webová aplikace spuštěna (přes WebSocket)",
"prefs_notifications_web_push_disabled": "Zakázáno",
"prefs_appearance_theme_title": "Motiv",
"prefs_appearance_theme_system": "Systém (výchozí)",
"prefs_system_default": "Systém výchozí",
"prefs_appearance_theme_dark": "Tmavý režim",
"prefs_appearance_theme_light": "Světlý režim",
"web_push_subscription_expiring_title": "Oznámení budou pozastavena",
+58 -4
View File
@@ -236,8 +236,8 @@
"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_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",
@@ -395,7 +395,7 @@
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
"prefs_notifications_web_push_disabled": "Deaktiviert",
"prefs_appearance_theme_title": "Thema",
"prefs_appearance_theme_system": "System (Standard)",
"prefs_system_default": "System Standard",
"prefs_appearance_theme_dark": "Nachtmodus",
"prefs_appearance_theme_light": "Tagmodus",
"error_boundary_button_reload_ntfy": "ntfy neu laden",
@@ -405,5 +405,59 @@
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Bereitgestelltes Token kann nicht bearbeitet oder gelöscht werden",
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden"
"account_basics_cannot_edit_or_delete_provisioned_user": "Ein bereitgestellter Benutzer kann nicht bearbeitet oder gelöscht werden",
"common_close": "Schließen",
"common_refresh": "Aktualisieren",
"email_verify_progress_title": "Deine E-Mail-Adresse wird verifiziert...",
"email_verify_success_title": "E-Mail-Adresse verifiziert",
"email_verify_success_description": "Deine E-Mail-Adresse wurde verifiziert und Deinem Konto hinzugefügt.",
"email_verify_error_title": "Verifizierung fehlgeschlagen",
"email_verify_error_description": "Dieser Verifizierungslink ist ungültig oder abgelaufen. Du kannst in Deinen Konto-Einstellungen einen neuen anfordern.",
"email_verify_button_account": "Zum Konto",
"version_update_available_title": "Neue Version verfügbar",
"version_update_available_description": "Der ntfy-Server wurde aktualisiert. Bitte lade die Seite neu.",
"signup_form_email": "E-Mail (optional, zur Konto-Wiederherstellung)",
"login_link_forgot_password": "Kennwort vergessen",
"reset_password_request_title": "Kennwort zurücksetzen",
"reset_password_request_description": "Gib Deinen Benutzernamen oder Deine E-Mail-Adresse ein. Falls ein Konto existiert, wird ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
"reset_password_request_primary_required": "Dies funktioniert nur, wenn Du bereits eine primäre E-Mail-Adresse hinzugefügt und verifiziert hast.",
"reset_password_request_identifier_label": "Benutzername oder E-Mail",
"reset_password_request_button_submit": "Link zum Zurücksetzen senden",
"reset_password_sent_title": "Überprüfe Deinen Posteingang",
"reset_password_sent_description": "Falls ein Konto existiert, wurde ein Link zum Zurücksetzen des Kennworts per E-Mail gesendet.",
"reset_password_back_to_login": "Zurück zur Anmeldung",
"reset_password_disabled": "Das Zurücksetzen des Kennworts ist deaktiviert",
"reset_password_title": "Neues Kennwort festlegen",
"reset_password_form_password": "Neues Kennwort",
"reset_password_form_confirm": "Neues Kennwort bestätigen",
"reset_password_form_button_submit": "Kennwort festlegen",
"reset_password_form_error_invalid": "Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
"reset_password_success_title": "Kennwort geändert",
"reset_password_success_description": "Dein Kennwort wurde geändert. Du kannst Dich nun mit Deinem neuen Kennwort anmelden.",
"action_bar_reload": "App neu laden",
"account_basics_emails_title": "E-Mail-Adressen",
"account_basics_emails_description": "Für E-Mail-Benachrichtigungen und das Zurücksetzen des Kennworts",
"account_basics_emails_no_emails_yet": "Noch keine E-Mail-Adressen",
"account_basics_emails_copied_to_clipboard": "E-Mail-Adresse in die Zwischenablage kopiert",
"account_basics_emails_chip_actions_primary": "Primäre Adresse, wird als Deine standardmäßige E-Mail-Adresse verwendet. Klicke für Aktionen.",
"account_basics_emails_chip_actions_verified": "Kann für Benachrichtigungen verwendet werden. Klicke für Aktionen.",
"account_basics_emails_chip_actions_unverified": "Nicht verifizierte Adresse, überprüfe Deinen Posteingang, um sie zu verifizieren. Klicke für Aktionen.",
"account_basics_emails_unverified": "nicht verifiziert",
"account_basics_emails_set_primary": "Als primäre E-Mail-Adresse festlegen",
"account_basics_emails_delete": "Adresse entfernen",
"account_basics_emails_resend": "Verifizierungs-E-Mail erneut senden",
"account_basics_emails_resent": "Verifizierungs-E-Mail gesendet, überprüfe Deinen Posteingang",
"account_basics_emails_primary_elsewhere": "Diese E-Mail-Adresse wird als primäre Adresse eines anderen Kontos verwendet",
"account_basics_emails_no_recovery_warning": "Füge mindestens eine E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
"account_basics_emails_no_primary_warning": "Füge eine primäre E-Mail-Adresse hinzu, damit Du Dein Konto wiederherstellen kannst, falls Du Dein Kennwort verlierst.",
"account_basics_emails_dialog_title": "E-Mail-Adresse hinzufügen",
"account_basics_emails_dialog_description": "Gib eine E-Mail-Adresse ein, um sie Deinem Konto hinzuzufügen. Es wird ein Verifizierungslink gesendet, um zu bestätigen, dass sie Dir gehört.",
"account_basics_emails_dialog_email_label": "E-Mail-Adresse",
"account_basics_emails_dialog_email_placeholder": "z.B. user@example.com",
"account_basics_emails_dialog_verify_button": "Verifizierungslink senden",
"account_basics_emails_dialog_check_inbox": "Überprüfe Deinen Posteingang und klicke auf den Verifizierungslink, um diese E-Mail-Adresse zu bestätigen. Sie wird als nicht verifiziert angezeigt, bis Du dies tust.",
"account_basics_tier_provisioned": "Bereitgestellt",
"account_usage_emails_none": "Mit diesem Konto können keine E-Mail-Benachrichtigungen gesendet werden",
"prefs_users_dialog_base_url_invalid": "Ungültiges URL-Format. Muss mit http:// oder https:// beginnen",
"prefs_users_dialog_base_url_exists": "Für diese Service-URL existiert bereits ein Benutzer"
}
+54 -13
View File
@@ -3,12 +3,20 @@
"common_save": "Save",
"common_add": "Add",
"common_back": "Back",
"common_close": "Close",
"common_copy_to_clipboard": "Copy to clipboard",
"common_refresh": "Refresh",
"email_verify_progress_title": "Verifying your email...",
"email_verify_success_title": "Email verified",
"email_verify_success_description": "Your email address has been verified and added to your account.",
"email_verify_error_title": "Verification failed",
"email_verify_error_description": "This verification link is invalid or has expired. You can request a new one from your account settings.",
"email_verify_button_account": "Go to account",
"version_update_available_title": "New version available",
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
"signup_title": "Create a ntfy account",
"signup_form_username": "Username",
"signup_form_email": "Email (optional, for account recovery)",
"signup_form_password": "Password",
"signup_form_confirm_password": "Confirm password",
"signup_form_button_submit": "Sign up",
@@ -20,6 +28,23 @@
"login_title": "Sign in to your ntfy account",
"login_form_button_submit": "Sign in",
"login_link_signup": "Sign up",
"login_link_forgot_password": "Forgot password",
"reset_password_request_title": "Reset password",
"reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed.",
"reset_password_request_primary_required": "This only works if you already added a primary email address and verified it.",
"reset_password_request_identifier_label": "Username or email",
"reset_password_request_button_submit": "Send reset link",
"reset_password_sent_title": "Check your inbox",
"reset_password_sent_description": "If an account exists, a link to reset your password has been emailed.",
"reset_password_back_to_login": "Back to sign-in",
"reset_password_disabled": "Password reset is disabled",
"reset_password_title": "Set a new password",
"reset_password_form_password": "New password",
"reset_password_form_confirm": "Confirm new password",
"reset_password_form_button_submit": "Set password",
"reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.",
"reset_password_success_title": "Password changed",
"reset_password_success_description": "Your password has been changed. You can now sign in with your new password.",
"login_disabled": "Login is disabled",
"action_bar_show_menu": "Show menu",
"action_bar_logo_alt": "ntfy logo",
@@ -42,6 +67,7 @@
"action_bar_profile_logout": "Logout",
"action_bar_sign_in": "Sign in",
"action_bar_sign_up": "Sign up",
"action_bar_reload": "Reload app",
"message_bar_type_message": "Type a message here",
"message_bar_error_publishing": "Error publishing notification",
"message_bar_show_dialog": "Show publish dialog",
@@ -216,18 +242,26 @@
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Call",
"account_basics_emails_title": "Email addresses",
"account_basics_emails_description": "For email notifications",
"account_basics_emails_no_emails_yet": "No verified emails yet",
"account_basics_emails_description": "For email notifications and password reset",
"account_basics_emails_no_emails_yet": "No emails yet",
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
"account_basics_emails_chip_actions_primary": "Primary address, used as your default email address. Click for actions.",
"account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.",
"account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.",
"account_basics_emails_unverified": "unverified",
"account_basics_emails_set_primary": "Set as primary email",
"account_basics_emails_delete": "Remove address",
"account_basics_emails_resend": "Resend verification email",
"account_basics_emails_resent": "Verification email sent, check your inbox",
"account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account",
"account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.",
"account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.",
"account_basics_emails_dialog_title": "Add email address",
"account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.",
"account_basics_emails_dialog_description": "Enter an email address to add it to your account. A verification link will be sent to confirm it is yours.",
"account_basics_emails_dialog_email_label": "Email address",
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
"account_basics_emails_dialog_verify_button": "Add email",
"account_basics_emails_dialog_code_label": "Verification code",
"account_basics_emails_dialog_code_placeholder": "e.g. 123456",
"account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired",
"account_basics_emails_dialog_check_verification_button": "Confirm",
"account_basics_emails_dialog_verify_button": "Send verification link",
"account_basics_emails_dialog_check_inbox": "Check your inbox and click the verification link to confirm this email address. It will appear as unverified until you do.",
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
"account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}",
@@ -236,9 +270,10 @@
"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_admin_suffix_with_tier": "with {{tier}} tier",
"account_basics_tier_admin_suffix_no_tier": "no tier",
"account_basics_tier_basic": "Basic",
"account_basics_tier_provisioned": "Provisioned",
"account_basics_tier_free": "Free",
"account_basics_tier_interval_monthly": "monthly",
"account_basics_tier_interval_yearly": "annually",
@@ -286,7 +321,6 @@
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls",
"account_upgrade_dialog_tier_features_no_calls": "No phone calls",
"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}}.",
@@ -378,9 +412,17 @@
"prefs_appearance_title": "Appearance",
"prefs_appearance_language_title": "Language",
"prefs_appearance_theme_title": "Theme",
"prefs_appearance_theme_system": "System (default)",
"prefs_system_default": "System default",
"prefs_appearance_theme_dark": "Dark mode",
"prefs_appearance_theme_light": "Light mode",
"prefs_appearance_date_format_title": "Date format",
"prefs_appearance_date_format_iso8601": "ISO 8601",
"prefs_appearance_date_format_dmy": "Day/Month/Year",
"prefs_appearance_date_format_dmy_dot": "Day.Month.Year",
"prefs_appearance_date_format_mdy": "Month/Day/Year",
"prefs_appearance_time_format_title": "Time format",
"prefs_appearance_time_format_12h": "12-hour",
"prefs_appearance_time_format_24h": "24-hour",
"prefs_reservations_title": "Reserved topics",
"prefs_reservations_description": "You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_limit_reached": "You reached your reserved topics limit.",
@@ -418,7 +460,6 @@
"error_boundary_button_copy_stack_trace": "Copy stack trace",
"error_boundary_button_reload_ntfy": "Reload ntfy",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_gathering_info": "Gather more info …",
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
"web_push_subscription_expiring_title": "Notifications will be paused",
+22 -2
View File
@@ -395,7 +395,7 @@
"prefs_notifications_web_push_enabled_description": "Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)",
"prefs_notifications_web_push_disabled": "Desactivado",
"prefs_appearance_theme_title": "Tema",
"prefs_appearance_theme_system": "Sistema (por defecto)",
"prefs_system_default": "Sistema por defecto",
"error_boundary_button_reload_ntfy": "Volver a cargar ntfy",
"web_push_subscription_expiring_title": "Las notificaciones se pausarán",
"prefs_notifications_web_push_disabled_description": "Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)",
@@ -406,5 +406,25 @@
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Un usuario provisionado no se puede editar o eliminar",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "No se puede editar o eliminar un token provisionado",
"common_refresh": "Recargar",
"email_verify_progress_title": "Verificando tu correo...",
"email_verify_success_title": "Correo verificado",
"email_verify_success_description": "Tu correo electronico ha sido verificado y añadido a tu cuenta.",
"email_verify_error_title": "La verificacion falló",
"email_verify_error_description": "Este enlace de verificacion es invalido o ha expirado. Puedes solicitar unon nuevo desde los ajustes de tu cuenta.",
"email_verify_button_account": "Ir a cuenta",
"version_update_available_title": "Nueva versión disponible",
"version_update_available_description": "El servidor ntfy ha sido actualizado. Por favor recarga la página.",
"signup_form_email": "Correo (opcional, para recuperación de cuenta)",
"login_link_forgot_password": "Olvidé mi contraseña",
"reset_password_request_title": "Reiniciar contraseña",
"reset_password_request_description": "Introduce tu usuario o correo electrónico. Si una cuenta existe, un enlace para reiniciar tu contraseña será enviado a tu correo.",
"reset_password_request_primary_required": "Esto solo funciona si ya añadiste un correo eletrónico primario y lo verificaste.",
"reset_password_request_identifier_label": "Usuario o correo",
"reset_password_request_button_submit": "Enviar enlace de reinicio",
"reset_password_sent_title": "Checa tu bandeja de entrada",
"reset_password_sent_description": "Si una cuenta existe, un enlace para reiniciar tu contraseña se acaba de enviar a tu correo.",
"reset_password_back_to_login": "Regresar a iniciar sesión",
"reset_password_disabled": "Reinicio de contraseña ha sido deshabilitado"
}
+58 -4
View File
@@ -238,7 +238,7 @@
"account_basics_tier_title": "Kasutajakonto tüüp",
"account_basics_tier_description": "Sinu kasutajakonto õigused",
"account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt",
"prefs_appearance_theme_system": "Süsteemi kujundus",
"prefs_system_default": "Süsteemi kujundus",
"prefs_appearance_theme_dark": "Tume kujundus",
"prefs_appearance_theme_light": "Hele kujundus",
"prefs_reservations_title": "Reserveeritud teemad",
@@ -332,8 +332,8 @@
"subscribe_dialog_error_user_not_authorized": "Kasutajal {{username}} puudub volitus",
"account_usage_of_limit": "piirangust {{limit}}",
"account_usage_limits_reset_daily": "Kasutuspiirangud lähtestatakse keskööl (UTC järgi)",
"account_basics_tier_admin_suffix_with_tier": "(tasemega {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(tase puudub)",
"account_basics_tier_admin_suffix_with_tier": "tasemega {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "tase puudub",
"account_upgrade_dialog_title": "Muuda kasutajakonto taset",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} faili kohta",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} kõnet päevas",
@@ -405,5 +405,59 @@
"account_upgrade_dialog_reservations_warning_other": "Sinu praegune teenusepakett võimaldab senise paketiga võrreldes reserveerida vähem teemasid. Enne paketi muutmist <strong>palun esmalt kustuta vähemalt {{count}} reserveeringut</strong>. Seda saad <Link>teha siin</Link>.",
"prefs_users_description": "Oma kaitstud teemade kasutajaid saad lisada ja eemaldada siin. Palun arvesta, et kasutajanimi ja salasõna on salvestatud veebibrauseri kohalikus andmeruumis.",
"account_basics_cannot_edit_or_delete_provisioned_user": "Eelsisestatud kasutajat ei saa muuta ega kustutada",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Eelsisestatud tunnusluba ei saa muuta ega kustutada"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Eelsisestatud tunnusluba ei saa muuta ega kustutada",
"common_close": "Sulge",
"common_refresh": "Värskenda andmeid",
"email_verify_progress_title": "Kontrollin sinu e-posti aadressi…",
"email_verify_success_title": "E-posti aadress on kontrollitud",
"email_verify_success_description": "Sinu a-posti aadress on kontrollitud ja lisatud sinu kasutajakontole.",
"email_verify_error_title": "Õigsuse kontrollimine ei õnnestunud",
"login_link_forgot_password": "Unustasin salasõna",
"reset_password_request_title": "Lähtesta salasõna",
"reset_password_request_description": "Sisesta oma kasutajanimi või e-posti aadress. Kui selline kasutajakonto on olemas, siis saadame seotud e-posti aadressile kirja salasõna lähtestamise lingiga.",
"reset_password_request_primary_required": "Selline võimalus toimib vaid siis, kui kasutajakontoga on seotud põhiline e-posti aadress ja see on kinnitatud olekus.",
"reset_password_request_identifier_label": "Kasutajanimi või e-posti aadress",
"reset_password_request_button_submit": "Saada lähtestamise link",
"reset_password_sent_title": "Vaata oma saabuvate e-kirjade postkasti",
"version_update_available_title": "Saadaval on uus versioon",
"version_update_available_description": "ntfy server on uuendatud. Palun laadi leht uuesti.",
"signup_form_email": "E-posti aadress (pole kohustuslik, aga on kasutatav kasutajakontole ligipääsu taastamisel)",
"reset_password_back_to_login": "Tagasi sisselogimiseks",
"reset_password_disabled": "Salasõnade lähtestamise võimalus on lülitatud välja",
"reset_password_title": "Sisesta uus salasõna",
"reset_password_form_password": "Uus salasõna",
"reset_password_form_confirm": "Kinnita uus salasõna",
"reset_password_form_button_submit": "Sisesta salasõna",
"reset_password_form_error_invalid": "Lähtestamise link on vigane või aegunud. Palun saada see link endale uuesti.",
"reset_password_success_title": "Salasõna on muudetud",
"reset_password_success_description": "Sinu salasõna on muudetud. Saad nüüd uue salasõnaga sisse logida.",
"action_bar_reload": "Laadi rakendus uuesti",
"account_basics_emails_title": "E-posti aadressid",
"account_basics_emails_description": "E-kirjaga saadetavate teavituste ja salasõna lähtestamise jaoks",
"account_basics_emails_no_emails_yet": "E-posti aadresse veel pole",
"account_basics_emails_copied_to_clipboard": "E-posti aadress on kopeeritud lõikelauale",
"account_basics_emails_delete": "Eemalda aadress",
"account_basics_emails_set_primary": "Määra põhiliseks e-posti aadressika",
"account_basics_emails_resend": "Saada kinnituskiri uuesti",
"account_basics_emails_resent": "Kinnituskiri on saadetud, vaata oma saabuvate kirjade postkasti",
"account_basics_emails_dialog_email_label": "E-posti aadress",
"account_basics_emails_dialog_email_placeholder": "nt. kadri@toredomeen.com",
"prefs_users_dialog_base_url_exists": "Selle teenuse võrguaadressi jaoks on kasutaja juba olemas",
"prefs_users_dialog_base_url_invalid": "Vigane võrguaadressi vorming. Alguses peab olema http:// või https://",
"account_basics_emails_dialog_verify_button": "Saada kinnituslink",
"account_basics_emails_dialog_check_inbox": "Vaata oma saabuvate e-kirjade postkasti ja klõpsa selle e-posti aadressi kinnituskirjas leiduvat linki. Seni on e-posti aadress kinnitamata olekus.",
"account_basics_tier_provisioned": "Ettevalmistatud",
"account_basics_emails_chip_actions_primary": "Põhiline e-posti aadress, mida kasutatakse vaikimisi aadressina. Tegevuste nägemiseks klõpsa.",
"account_basics_emails_chip_actions_verified": "Võid kasutada ka teavituste jaoks. Tegevuste nägemiseks klõpsa.",
"account_basics_emails_chip_actions_unverified": "Kinnitamata e-posti aadress, kinnitamiseks vaata oma saabuvate kirjade postkasti. Tegevuste nägemiseks klõpsa.",
"account_basics_emails_unverified": "kinnitamata",
"email_verify_error_description": "Kinnituse link on vigane või aegunud. Oma kasutajakonto seadistustest võid saata uue.",
"email_verify_button_account": "Mine kasutajakonto juurde",
"reset_password_sent_description": "Kui selline kasutajakonto on olemas, saadetakse sinna e-kiri salasõna lähtestamiseks vajaliku lingiga.",
"account_basics_emails_primary_elsewhere": "Seda e-posti aadressi kasutatakse põhilise e-postiaadressina ühe teise kasutajakonto juures",
"account_basics_emails_no_recovery_warning": "Tagamaks, et saada peale salasõna unustamist/kaotamist vajadusel taastada ligipääsu oma kasutajakontole lisa vähemalt üks e-posti aadress.",
"account_basics_emails_no_primary_warning": "Tagamaks, et saada peale salasõna unustamist/kaotamist vajadusel taastada ligipääsu oma kasutajakontole põhiline e-posti aadress.",
"account_basics_emails_dialog_title": "Lisa e-posti aadress",
"account_basics_emails_dialog_description": "Sisesta kasutajakontole lisatav e-posti aadress. Kinnitamaks, et tegemist on sinu e-posti aadressiga saadame sinna kinnituslingi.",
"account_usage_emails_none": "Selle kasutajakontoga seotud teavitusi pole võimalik saata"
}
+1 -1
View File
@@ -390,7 +390,7 @@
"alert_notification_ios_install_required_title": "iOS-asennus vaaditaan",
"publish_dialog_checkbox_markdown": "Muotoile Markdownina",
"prefs_notifications_web_push_title": "Taustailmoitukset",
"prefs_appearance_theme_system": "Järjestelmä (oletus)",
"prefs_system_default": "Järjestelmä oletus",
"alert_notification_permission_denied_description": "Ota ilmoitukset uudelleen käyttöön selaimessa",
"prefs_appearance_theme_title": "Teema",
"prefs_appearance_theme_light": "Vaalea tila",
+59 -5
View File
@@ -64,7 +64,7 @@
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
"publish_dialog_tags_placeholder": "Liste d'étiquettes séparée par des virgules, par ex. avertissement, backup-srv1",
"publish_dialog_tags_placeholder": "Liste d'étiquettes séparées par des virgules, par ex. avertissement, backup-srv1",
"publish_dialog_priority_label": "Priorité",
"publish_dialog_click_label": "URL du clic",
"publish_dialog_click_placeholder": "URL ouverte lors d'un clic sur la notification",
@@ -248,8 +248,8 @@
"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_admin_suffix_with_tier": "avec le niveau {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "pas de niveau",
"account_basics_tier_free": "Gratuit",
"account_basics_tier_upgrade_button": "Passer à Pro",
"account_basics_tier_change_button": "Changer",
@@ -396,7 +396,7 @@
"prefs_notifications_web_push_enabled": "Activé pour {{server}}",
"prefs_notifications_web_push_disabled": "Désactivé",
"prefs_appearance_theme_title": "Thème",
"prefs_appearance_theme_system": "Système (défaut)",
"prefs_system_default": "Système défaut",
"prefs_appearance_theme_dark": "Mode sombre",
"prefs_appearance_theme_light": "Mode clair",
"error_boundary_button_reload_ntfy": "Recharger ntfy",
@@ -405,5 +405,59 @@
"web_push_unknown_notification_title": "Notification inconnue reçue du serveur",
"web_push_unknown_notification_body": "Il est possible que vous deviez mettre à jour ntfy en ouvrant l'application web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Un utilisateur provisionné ne peut pas être modifié ou supprimé",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossible de modifier ou de supprimer le jeton provisionné"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossible de modifier ou de supprimer le jeton provisionné",
"common_close": "Fermer",
"common_refresh": "Rafraichir",
"email_verify_progress_title": "Vérification de votre email...",
"email_verify_success_title": "Email vérifié",
"email_verify_success_description": "Votre adresse mail a été vérifiée et ajoutée à votre compte.",
"email_verify_error_title": "Echec de la vérification",
"email_verify_error_description": "Ce lien de vérification est invalide ou a expiré. Vous pouvez en demander un nouveau depuis les paramètres de votre compte.",
"email_verify_button_account": "Aller au compte",
"version_update_available_title": "Nouvelle version disponible",
"version_update_available_description": "Le serveur ntfy a été lis à jour. Veuillez rafraichir la page.",
"signup_form_email": "Email (optionnel, pour la récupération du compte)",
"login_link_forgot_password": "Mot de passe oublié",
"reset_password_request_title": "Réinitialiser le mot de passe",
"reset_password_request_description": "Entrer votre nom d'utilisateur ou une adresse mail. Si un compte existe, un lien pour réinitialiser votre mot de passe sera envoyé par mail.",
"reset_password_request_primary_required": "Ceci ne fonctionne que si vous avez ajouté une adresse mail primaire et que vous l'avez vérifiée.",
"reset_password_request_identifier_label": "Nom d'utilisateur ou email",
"reset_password_request_button_submit": "Envoyer un lien de réinitialisation",
"reset_password_sent_title": "Vérifiez votre boîte de réception",
"reset_password_sent_description": "Si un compte existe, un lien pour réinitialiser votre mot de passe sera envoyé par mail.",
"reset_password_back_to_login": "Retour à l'authentification",
"reset_password_disabled": "La réinitialisation du mot de passe est désactivée",
"reset_password_title": "Définir un nouveau mot de passe",
"reset_password_form_password": "Nouveau mot de passe",
"reset_password_form_confirm": "Confirmer le nouveau mot de passe",
"reset_password_form_button_submit": "Définir le mot de passe",
"reset_password_form_error_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Merci d'en demander un nouveau.",
"reset_password_success_title": "Mot de passe modifié",
"reset_password_success_description": "Votre mot de passe a été changé. Vous pouvez maintenant vous authentifier avec votre nouveau mot de passe.",
"action_bar_reload": "Recharger l'application",
"account_basics_emails_title": "Adresses mail",
"account_basics_emails_description": "Pour les notifications par mail et la réinitialisation du mot de passe",
"account_basics_emails_no_emails_yet": "Pas encore d'emails",
"account_basics_emails_copied_to_clipboard": "Adresse mail copiée dans le presse papiers",
"account_basics_emails_chip_actions_primary": "Adresse primaire, utilisée comme votre adresse mail par défaut. Cliquer pour choisir l'action.",
"account_basics_emails_chip_actions_verified": "Peut être utilisée pour les notifications. Cliquer pour choisir l'action.",
"account_basics_emails_chip_actions_unverified": "Adresse non vérifiée, vérifier votre boîte de réception pour la vérifier. Cliquer pour choisir l'action.",
"account_basics_emails_unverified": "Non vérifié",
"account_basics_emails_set_primary": "Définir comme adresse mail primaire",
"account_basics_emails_delete": "Supprimer l'adresse",
"account_basics_emails_resend": "Renvoyer le mail de vérification",
"account_basics_emails_resent": "Mail de vérification envoyé, vérifier votre boite de réception",
"account_basics_emails_primary_elsewhere": "Cette adresse mail est utilisée comme adresse primaire d'un autre compte",
"account_basics_emails_no_recovery_warning": "Ajouter au moins une adresse mail pour garantir que vous pouvez récupérer votre compte si vous oubliez votre mot de passe.",
"account_basics_emails_no_primary_warning": "Ajouter une adresse mail primaire pour garantir la récupération de votre compte si vous oubliez votre mot de paqsse.",
"account_basics_emails_dialog_title": "Ajouter une adresse mail",
"account_basics_emails_dialog_description": "Entrer une adresse mail pour l'ajouter à votre compte. Un lien de vérification sera envoyé pour confirmer votre adresse.",
"account_basics_emails_dialog_email_label": "Adresse mail",
"account_basics_emails_dialog_email_placeholder": "par ex. utilisateur@example.com",
"account_basics_emails_dialog_verify_button": "Envoyer le lien de vérification",
"account_basics_emails_dialog_check_inbox": "Vérifier votre boîte de réception et cliquer sur le lien de vérification pour confirmer cette adresse mail. Elle apparaitra non vérifiée tant qu'elle n'aura pas été vérifiée.",
"account_basics_tier_provisioned": "Provisionné",
"account_usage_emails_none": "Aucune notification par mail ne peut être envoyée avec ce compte",
"prefs_users_dialog_base_url_invalid": "Format d'URL invalide. Doit commencer par http:// ou https://",
"prefs_users_dialog_base_url_exists": "Un utilisateur pour cette URL existe déjà"
}
+1 -1
View File
@@ -398,7 +398,7 @@
"prefs_notifications_web_push_enabled": "Activadas para {{server}}",
"prefs_notifications_web_push_disabled": "Desactivadas",
"prefs_appearance_theme_title": "Decorado",
"prefs_appearance_theme_system": "Sistema (por defecto)",
"prefs_system_default": "Sistema por defecto",
"prefs_appearance_theme_dark": "Modo escuro",
"prefs_appearance_theme_light": "Modo claro",
"error_boundary_button_reload_ntfy": "Recargar ntfy",
+399 -163
View File
@@ -1,227 +1,463 @@
{
"action_bar_send_test_notification": "Teszt értesítés küldése",
"action_bar_clear_notifications": "Összes értesítés törlése",
"alert_not_supported_description": "A böngésződ nem támogatja az értesítések fogadását",
"action_bar_send_test_notification": "Tesztértesítés küldése",
"action_bar_clear_notifications": "Az összes értesítés törlése",
"alert_not_supported_description": "A böngésző nem támogatja az értesítéseket",
"action_bar_settings": "Beállítások",
"action_bar_unsubscribe": "Leiratkozás",
"message_bar_type_message": "Írd ide az üzenetet",
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
"nav_button_all_notifications": "Összes értesítés",
"message_bar_type_message": "Írj ide egy üzenetet",
"message_bar_error_publishing": "Hiba az értesítés közzétételénél",
"nav_button_all_notifications": "Minden értesítés",
"nav_topics_title": "Feliratkozott témák",
"alert_notification_permission_required_title": "Az értesítések le vannak tiltva",
"alert_notification_permission_required_description": "Engedélyezd a böngésződnek, hogy asztali értesítéseket jelenítsen meg",
"alert_notification_permission_required_description": "Engedélyezze a böngészőjében az asztali értesítések megjelenítését",
"nav_button_settings": "Beállítások",
"nav_button_documentation": "Dokumentáció",
"nav_button_publish_message": "Értesítés küldése",
"alert_notification_permission_required_button": "Engedélyezés",
"alert_not_supported_title": "Az értesítések nincsenek támogatva",
"notifications_copied_to_clipboard": "Vágólapra másolva",
"nav_button_publish_message": "Értesítés közzététele",
"alert_notification_permission_required_button": "Jelentkezz most",
"alert_not_supported_title": "Az értesítések nem támogatottak",
"notifications_copied_to_clipboard": "A vágólapra másolva",
"notifications_tags": "Címkék",
"notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét",
"notifications_attachment_copy_url_title": "Copy attachment URL to clipboard",
"notifications_attachment_copy_url_button": "URL másolása",
"notifications_attachment_open_title": "Menjen a(z) {{url}} címre",
"notifications_attachment_open_button": "Csatolmány megnyitása",
"notifications_attachment_link_expired": "A letöltési link lejárt",
"notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le",
"nav_button_subscribe": "Feliratkozás témára",
"notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét",
"notifications_actions_open_url_title": "Menjen a(z) {{url}} címre",
"notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban",
"notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre",
"notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.",
"notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.",
"notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.",
"notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.",
"publish_dialog_message_published": "Értesítés elküldve",
"notifications_attachment_open_title": "Ugrás a{{url}}-ra",
"notifications_attachment_open_button": "Melléklet megnyitása",
"notifications_attachment_link_expired": "A letöltési link érvényessége lejárt",
"notifications_attachment_link_expires": "A link érvényessége lejár:{{date}}",
"nav_button_subscribe": "Iratkozz fel a témára",
"notifications_click_copy_url_title": "A link URL-jét a vágólapra másolja",
"notifications_actions_open_url_title": "Ugrás a{{url}}-ra",
"notifications_actions_not_supported": "Ez a művelet nem támogatott a webalkalmazásban",
"notifications_actions_http_request_title": "HTTP-kérés küldése {{method}} címre {{url}}",
"notifications_none_for_topic_title": "Erre a témára még nem kaptál értesítést.",
"notifications_none_for_any_title": "Nem érkezett hozzád értesítés.",
"notifications_none_for_any_description": "Ha értesítéseket szeretnél küldeni egy témához, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére. Íme egy példa az egyik témádra vonatkozóan.",
"notifications_no_subscriptions_title": "Úgy tűnik, még nincs előfizetésed.",
"publish_dialog_message_published": "Értesítés közzététele",
"notifications_example": "Példa",
"notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.",
"notifications_no_subscriptions_description": "Kattints a „{{linktext}}” linkre egy téma létrehozásához vagy feliratkozáshoz. Ezt követően PUT vagy POST kéréssel küldhetsz üzeneteket, és itt fogod megkapni az értesítéseket.",
"publish_dialog_priority_low": "Alacsony prioritás",
"publish_dialog_priority_default": "Közepes prioritás",
"publish_dialog_priority_high": "Magas prioritás",
"notifications_more_details": "További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.",
"publish_dialog_title_no_topic": "Értesítés küldése",
"publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)",
"publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt",
"publish_dialog_priority_min": "Legkisebb prioritás",
"publish_dialog_base_url_label": "A szolgáltatás URL-e",
"publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com",
"publish_dialog_topic_label": "Téma neve",
"publish_dialog_priority_max": "Legmagasabb prioritás",
"publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
"publish_dialog_priority_default": "Alapértelmezett prioritás",
"publish_dialog_priority_high": "Kiemelt fontosságú",
"notifications_more_details": "További információkért látogasson el a<websiteLink>weboldalra, vagy tekintse meg a</websiteLink>vagy a<docsLink>dokumentációt.",
"publish_dialog_title_no_topic": "Értesítés közzététele",
"publish_dialog_attachment_limits_file_and_quota_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot és kvótát,{{remainingBytes}}maradt",
"publish_dialog_attachment_limits_quota_reached": "meghaladja a kvótát, {{remainingBytes}} maradt",
"publish_dialog_priority_min": "Minimális prioritás",
"publish_dialog_base_url_label": "Szolgáltatás URL-je",
"publish_dialog_base_url_placeholder": "Szolgáltatás URL-címe, pl. https://example.com",
"publish_dialog_topic_label": "A téma neve",
"publish_dialog_priority_max": "Legfőbb prioritás",
"publish_dialog_topic_placeholder": "Téma neve, pl. phil_alerts",
"publish_dialog_title_label": "Cím",
"publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely",
"publish_dialog_title_placeholder": "Értesítés címe, pl. Lemezterület-figyelmeztetés",
"publish_dialog_message_label": "Üzenet",
"publish_dialog_message_placeholder": "Írj ide egy üzenetet",
"publish_dialog_tags_label": "Címkék",
"publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup",
"publish_dialog_priority_label": "Prioritás",
"publish_dialog_click_label": "URL",
"publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com",
"publish_dialog_attach_label": "Csatolmány URL-e",
"publish_dialog_tags_placeholder": "Vesszővel elválasztott címkék listája, pl.: warning, srv1-backup",
"publish_dialog_priority_label": "Elsőbbség",
"publish_dialog_click_label": "Kattintson az URL-re",
"publish_dialog_click_placeholder": "Az értesítésre kattintáskor megnyíló URL",
"publish_dialog_email_label": "E-mail",
"publish_dialog_email_placeholder": "A bejelentés továbbításának címe, pl.: phil@example.com",
"publish_dialog_attach_label": "A melléklet URL-címe",
"publish_dialog_filename_label": "Fájlnév",
"publish_dialog_filename_placeholder": "Csatolmány fájlneve",
"publish_dialog_filename_placeholder": "A melléklet fájlneve",
"publish_dialog_delay_label": "Késleltetés",
"publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)",
"publish_dialog_other_features": "Egyéb lehetőségek:",
"publish_dialog_chip_click_label": "Kattintási URL",
"publish_dialog_delay_placeholder": "Szállítás késleltetése, pl. {{unixTimestamp}}, {{relativeTime}}vagy „{{naturalLanguage}}” (csak angolul)",
"publish_dialog_other_features": "Egyéb jellemzők:",
"publish_dialog_chip_click_label": "Kattintson az URL-re",
"publish_dialog_chip_attach_file_label": "Helyi fájl csatolása",
"publish_dialog_chip_delay_label": "Késleltetett kézbesítés",
"publish_dialog_chip_topic_label": "Téma megváltoztatása",
"publish_dialog_button_cancel_sending": "Küldés megállítása",
"publish_dialog_button_cancel": "Mégsem",
"publish_dialog_checkbox_publish_another": "Küldök még egyet",
"publish_dialog_chip_delay_label": "Szállítás késleltetése",
"publish_dialog_chip_topic_label": "Téma váltása",
"publish_dialog_button_cancel_sending": "Elküldés visszavonása",
"publish_dialog_button_cancel": "Mégse",
"publish_dialog_checkbox_publish_another": "Újabb közzététel",
"publish_dialog_attached_file_title": "Csatolt fájl:",
"publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve",
"publish_dialog_drop_file_here": "Ejtsd ide a fájlt",
"emoji_picker_search_placeholder": "Emoji keresése",
"publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.",
"publish_dialog_attached_file_filename_placeholder": "A melléklet fájlneve",
"publish_dialog_drop_file_here": "Helyezze ide a fájlt",
"emoji_picker_search_placeholder": "Emoji keresés",
"publish_dialog_details_examples_description": "Példákért és az összes küldési funkció részletes leírásáért kérjük, olvassa el a <docsLink>dokumentációt</docsLink>.",
"subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert",
"subscribe_dialog_subscribe_button_subscribe": "Feliratkozás",
"subscribe_dialog_login_title": "Be kell jelentkezni",
"subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.",
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
"subscribe_dialog_subscribe_button_subscribe": "Iratkozz fel",
"subscribe_dialog_login_title": "Bejelentkezés szükséges",
"subscribe_dialog_subscribe_description": "A témák nem jelszóval védhetők, ezért válasszon olyan nevet, amelyet nem könnyű kitalálni. A feliratkozás után PUT/POST értesítéseket küldhet.",
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Kérjük, adja meg a felhasználónevét és a jelszavát a feliratkozáshoz.",
"subscribe_dialog_login_username_label": "Felhasználónév, pl. phil",
"subscribe_dialog_login_password_label": "Jelszó",
"common_back": "Vissza",
"subscribe_dialog_login_button_login": "Belépés",
"subscribe_dialog_login_button_login": "Bejelentkezés",
"subscribe_dialog_error_user_anonymous": "névtelen",
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
"prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül",
"prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg",
"prefs_notifications_min_priority_any": "Bármilyen prioritás",
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb",
"prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás",
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás",
"prefs_notifications_sound_title": "Értesítés hangja",
"prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek",
"prefs_notifications_sound_no_sound": "Hang nélkül",
"prefs_notifications_delete_after_one_week": "1 hét után",
"prefs_notifications_delete_after_one_month": "1 hónap után",
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve",
"prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése",
"prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése",
"prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.",
"subscribe_dialog_error_user_not_authorized": "A{{username}}felhasználó nem rendelkezik jogosultsággal",
"prefs_notifications_min_priority_description_any": "Az összes értesítés megjelenítése, prioritástól függetlenül",
"prefs_notifications_min_priority_description_max": "Értesítések megjelenítése, ha a prioritás 5 (maximális)",
"prefs_notifications_min_priority_any": "Bármely prioritás",
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás és annál magasabb",
"prefs_notifications_min_priority_high_and_higher": "Magas prioritás és annál magasabb",
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritású",
"prefs_notifications_sound_title": "Értesítési hang",
"prefs_notifications_sound_description_none": "Az értesítések érkezésekor nem hallatszik hang",
"prefs_notifications_sound_no_sound": "Nincs hang",
"prefs_notifications_delete_after_one_week": "Egy hét elteltével",
"prefs_notifications_delete_after_one_month": "Egy hónap elteltével",
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem kerülnek automatikusan törlésre",
"prefs_notifications_delete_after_three_hours_description": "Az értesítések három óra elteltével automatikusan törlődnek",
"prefs_notifications_delete_after_one_day_description": "Az értesítések egy nap elteltével automatikusan törlődnek",
"prefs_users_description": "Itt adhat hozzá vagy távolíthat el felhasználókat a védett témákhoz. Felhívjuk figyelmét, hogy a felhasználónév és a jelszó a böngésző helyi tárolójában kerülnek elmentésre.",
"prefs_users_table_user_header": "Felhasználó",
"prefs_users_table_base_url_header": "Szerver címe",
"prefs_users_table_base_url_header": "Szolgáltatás URL-je",
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
"prefs_users_dialog_username_label": "Felhasználónév, pl. phil",
"prefs_users_dialog_password_label": "Jelszó",
"common_add": "Hozzáadás",
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
"prefs_users_dialog_base_url_label": "Szolgáltatási URL, pl. https://ntfy.sh",
"notifications_loading": "Értesítések betöltése …",
"publish_dialog_progress_uploading": "Feltöltés …",
"notifications_click_copy_url_button": "Hivatkozás másolása",
"notifications_click_open_button": "Hivatkozás megnyitása",
"publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …",
"notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.",
"prefs_notifications_delete_after_one_day": "1 nap után",
"publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk",
"publish_dialog_chip_email_label": "Továbbítás email-ben",
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel",
"publish_dialog_progress_uploading": "Feltöltés…",
"notifications_click_copy_url_button": "Link másolása",
"notifications_click_open_button": "Link megnyitása",
"publish_dialog_progress_uploading_detail": "{{loaded}}/{{total}}feltöltése ({{percent}}%) …",
"notifications_none_for_topic_description": "Ha értesítéseket szeretnél küldeni erre a témára, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére.",
"prefs_notifications_delete_after_one_day": "Egy nap elteltével",
"publish_dialog_attach_placeholder": "Fájl csatolása URL-címen keresztül, pl. https://f-droid.org/F-Droid.apk",
"publish_dialog_chip_email_label": "Továbbítás e-mailben",
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-címen keresztül",
"publish_dialog_button_send": "Küldés",
"subscribe_dialog_subscribe_title": "Feliratkozás témára",
"subscribe_dialog_subscribe_button_cancel": "Mégsem",
"prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás",
"prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb",
"prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb",
"prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése",
"subscribe_dialog_subscribe_title": "Iratkozz fel a témára",
"subscribe_dialog_subscribe_button_cancel": "Mégse",
"prefs_notifications_min_priority_title": "Legalacsonyabb prioritás",
"prefs_notifications_min_priority_description_x_or_higher": "Értesítéseket jelenít meg, ha a prioritás {{number}} ({{name}}) vagy annál magasabb",
"prefs_notifications_min_priority_default_and_higher": "Alapértelmezett prioritás és annál magasabb",
"prefs_notifications_delete_after_one_week_description": "Az értesítések egy hét elteltével automatikusan törlődnek",
"prefs_users_add_button": "Felhasználó hozzáadása",
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl. phil_alerts",
"prefs_notifications_title": "Értesítések",
"error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása",
"prefs_notifications_delete_after_title": "Régi értesítések törlése",
"prefs_notifications_delete_after_three_hours": "3 óra után",
"error_boundary_title": "Jaj ne, az ntfy összeomlott",
"error_boundary_button_copy_stack_trace": "A veremnyomtatvány másolása",
"prefs_notifications_delete_after_title": "Értesítések törlése",
"prefs_notifications_delete_after_three_hours": "Három óra múlva",
"error_boundary_title": "Jaj, ne, az ntfy összeomlott",
"prefs_notifications_delete_after_never": "Soha",
"prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése",
"prefs_notifications_delete_after_one_month_description": "Az értesítések egy hónap elteltével automatikusan törlődnek",
"prefs_appearance_title": "Megjelenés",
"priority_default": "közepes",
"priority_default": "alapértelmezett",
"priority_high": "magas",
"priority_max": "legmagasabb",
"priority_min": "legkisebb",
"error_boundary_gathering_info": "Több információ…",
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
"priority_max": "legnagyobb",
"priority_min": "Nekem",
"error_boundary_gathering_info": "További információk …",
"publish_dialog_attachment_limits_file_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot",
"prefs_users_title": "Felhasználók kezelése",
"common_cancel": "Mégsem",
"common_cancel": "Mégse",
"common_save": "Mentés",
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
"prefs_appearance_language_title": "Nyelv",
"priority_low": "alacsony",
"error_boundary_stack_trace": "Verem nyomkövetés",
"publish_dialog_title_topic": "A {{topic}} téma értesítése",
"prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni",
"error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>.",
"action_bar_show_menu": "Menü mutatása",
"action_bar_toggle_mute": "Üzenetek némítása/bekapcsolása",
"error_boundary_stack_trace": "Hibajelentés",
"publish_dialog_title_topic": "Közzététel a {{topic}}-ban",
"prefs_notifications_sound_description_some": "Az értesítések érkezéskor a {{sound}} hangot játsszák le",
"error_boundary_description": "Ez természetesen nem lenne szabad, hogy megtörténjen. Nagyon sajnáljuk a kellemetlenséget.<br/>Ha van egy perced, kérlek, <githubLink>jelentsd be a hibát a GitHubon</githubLink>, vagy értesíts minket a <discordLink>Discordon</discordLink> vagy a <matrixLink>Matrixon</matrixLink>.",
"action_bar_show_menu": "Menü megjelenítése",
"action_bar_toggle_mute": "Értesítések némítása/némításának feloldása",
"notifications_list_item": "Értesítés",
"error_boundary_unsupported_indexeddb_description": "A ntfy web alkalmazás működéséhez szükséges az IndexedDB funkció, az ön böngészője nem támogatja az IndexedDB használatát privát böngészés közben.<br/><br/>Miközben privát mód sajnos nem lehetséges, szeretnénk értesíteni hogy magabiztosan használhatja normál módban mert a böngésző minden adatot az ön gépén tárol. Tovább tájékozódhat <githubLink>ezen a Github oldalon</githubLink>, vagy beszéljen velünk <discordLink>Discord-on</discordLink> vagy <matrixLink>Matrix-on</matrixLink>.",
"notifications_priority_x": "Prioritás {{prioritás}}",
"message_bar_show_dialog": "Küldött üzenetek megjelenítése",
"error_boundary_unsupported_indexeddb_description": "Az ntfy webalkalmazás működéséhez szükség van az IndexedDB-re, a böngésződ azonban nem támogatja az IndexedDB-t inkognitó módban.<br/><br/>Bár ez sajnálatos, valójában nem is lenne túl értelmes az ntfy webalkalmazást inkognitó módban használni, mivel minden a böngésző tárolójában kerül elmentésre. További információkat erről a GitHub-problémában találsz, vagy lépj kapcsolatba velünk a Discordon vagy a Matrixon.",
"notifications_priority_x": "Prioritás {{priority}}",
"message_bar_show_dialog": "Közzétételi párbeszédpanel megjelenítése",
"action_bar_logo_alt": "ntfy logó",
"action_bar_toggle_action_menu": "Tevékenységkezelő nyitása/zárása",
"message_bar_publish": "Üzenet küldése",
"nav_button_muted": "Értesítések némítva",
"nav_button_connecting": "csatlakozás",
"notifications_list": "Értesítés lista",
"notifications_mark_read": "Jelölés olvasottként",
"action_bar_toggle_action_menu": "Műveleti menü megnyitása/bezárása",
"message_bar_publish": "Üzenet közzététele",
"nav_button_muted": "Értesítések elnémítva",
"nav_button_connecting": "összekötő",
"notifications_list": "Értesítések listája",
"notifications_mark_read": "Olvasottként jelölés",
"notifications_delete": "Törlés",
"notifications_new_indicator": "Új értesítés",
"notifications_attachment_image": "Csatolt kép",
"notifications_attachment_file_image": "Kép fájl",
"notifications_attachment_file_video": "Videó fájl",
"notifications_attachment_file_audio": "Hang fájl",
"notifications_attachment_file_app": "Android alkalmazás fájl",
"notifications_attachment_image": "Melléklet kép",
"notifications_attachment_file_image": "képfájl",
"notifications_attachment_file_video": "videofájl",
"notifications_attachment_file_audio": "hangfájl",
"notifications_attachment_file_app": "Android-alkalmazásfájl",
"notifications_attachment_file_document": "egyéb dokumentum",
"publish_dialog_emoji_picker_show": "Emoji kiválasztása",
"publish_dialog_topic_reset": "Téma visszaállítása",
"publish_dialog_click_reset": "URL kattintás törlése",
"publish_dialog_email_reset": "Email továbbítás törlése",
"publish_dialog_attach_reset": "Csatolt URL törlése",
"publish_dialog_delay_reset": "Késleltetett kézbesítés törlése",
"publish_dialog_attached_file_remove": "Csatolt fájl törlése",
"publish_dialog_click_reset": "Az URL-re kattintás eltávolítása",
"publish_dialog_email_reset": "Az e-mail továbbításának megszüntetése",
"publish_dialog_attach_reset": "A melléklet URL-jének eltávolítása",
"publish_dialog_delay_reset": "A késleltetett kézbesítés eltávolítása",
"publish_dialog_attached_file_remove": "A csatolt fájl eltávolítása",
"emoji_picker_search_clear": "Keresés törlése",
"prefs_notifications_sound_play": "Kijelölt hang lejátszása",
"prefs_users_table": "Felhasználó táblázat",
"prefs_notifications_sound_play": "A kiválasztott hang lejátszása",
"prefs_users_table": "Felhasználók táblázata",
"prefs_users_edit_button": "Felhasználó szerkesztése",
"prefs_users_delete_button": "Felhasználó törlése",
"error_boundary_unsupported_indexeddb_title": "Privát böngészés nem támogatott",
"subscribe_dialog_subscribe_base_url_label": "Szolgáltató URL",
"error_boundary_unsupported_indexeddb_title": "A magánböngészés nem támogatott",
"subscribe_dialog_subscribe_base_url_label": "Szolgáltatás URL-je",
"signup_form_username": "Felhasználónév",
"signup_form_password": "Jelszó",
"signup_form_button_submit": "Regisztráció",
"signup_form_button_submit": "Regisztrálj",
"login_form_button_submit": "Bejelentkezés",
"login_link_signup": "Regisztráció",
"login_disabled": "Bejelentkezés kikapcsolva",
"action_bar_change_display_name": "Megjelenített név módosítása",
"login_link_signup": "Regisztrálj",
"login_disabled": "A bejelentkezés le van tiltva",
"action_bar_change_display_name": "A megjelenített név módosítása",
"action_bar_profile_logout": "Kijelentkezés",
"action_bar_sign_in": "Bejelentkezés",
"action_bar_sign_up": "Regisztráció",
"action_bar_sign_up": "Regisztrálj",
"action_bar_profile_title": "Profil",
"nav_button_account": "Fiók",
"common_copy_to_clipboard": "Másolás vágólapra",
"action_bar_reservation_limit_reached": "Limit elérve",
"login_title": "Jelentkezz be a ntfy felhasználódba",
"signup_title": "Hozz létre egy ntfy felhasználói fiókot",
"common_copy_to_clipboard": "Másolás a vágólapra",
"action_bar_reservation_limit_reached": "Elérte a határt",
"login_title": "Jelentkezzen be az ntfy-fiókjába",
"signup_title": "Hozzon létre egy ntfy-fiókot",
"signup_form_confirm_password": "Jelszó megerősítése",
"signup_already_have_account": "Már van felhasználód? Jelentkezz be!",
"signup_already_have_account": "Van már fiókod? Jelentkezz be!",
"action_bar_account": "Fiók",
"action_bar_profile_settings": "Beállítások",
"signup_error_username_taken": "A felhasználónév {{username}} már foglalt",
"signup_error_creation_limit_reached": "Felhasználói regisztráció limit elérve",
"signup_error_username_taken": "A{{username}}felhasználónév már foglalt",
"signup_error_creation_limit_reached": "Elérte a fiók létrehozásának korlátját",
"action_bar_mute_notifications": "Értesítések némítása",
"action_bar_unmute_notifications": "Értesítések némításának feloldása",
"alert_notification_permission_denied_title": "Az értesítések blokkolva vannak",
"alert_notification_permission_denied_description": "Kérjük kapcsold őket vissza a böngésződben",
"alert_notification_ios_install_required_title": "iOS telepítés szükséges",
"alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a <mdnLink>Notifications API</mdnLink> korlátozása.",
"signup_form_toggle_password_visibility": "Jelszó láthatóságának kapcsolása",
"action_bar_unmute_notifications": "Értesítések hangjának visszaállítása",
"alert_notification_permission_denied_title": "Az értesítések letiltva vannak",
"alert_notification_permission_denied_description": "Kérjük, kapcsolja be őket újra a böngészőjében",
"alert_notification_ios_install_required_title": "iOS-telepítés szükséges",
"alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a <mdnLink>Értesítési API</mdnLink>korlátozása.",
"signup_form_toggle_password_visibility": "A jelszó láthatóságának beállítása",
"signup_disabled": "A regisztráció le van tiltva",
"action_bar_reservation_add": "Téma fenntartása",
"action_bar_reservation_add": "Téma elmentése",
"action_bar_reservation_edit": "Foglalás módosítása",
"action_bar_reservation_delete": "Foglalás törlése",
"nav_upgrade_banner_label": "Frissítés ntfy Pro-ra",
"nav_upgrade_banner_description": "Témák, több üzenet és e-mail, valamint nagyobb mellékletek megőrzése",
"alert_notification_ios_install_required_description": "Kattintson a Megosztás ikonra, majd a Hozzáadás a kezdőképernyőhöz gombra, hogy engedélyezze az értesítéseket iOS rendszeren"
"nav_upgrade_banner_label": "Frissíts az ntfy Pro-ra",
"nav_upgrade_banner_description": "További témák, több üzenet és e-mail, valamint nagyobb mellékletek",
"alert_notification_ios_install_required_description": "Kattints a Megosztás ikonra, majd a „Hozzáadás a kezdőképernyőhöz” gombra az értesítések engedélyezéséhez iOS rendszeren",
"notifications_actions_failed_notification": "Sikertelen művelet",
"display_name_dialog_title": "A megjelenített név módosítása",
"display_name_dialog_description": "Állítson be egy alternatív nevet egy témához, amely az előfizetési listában jelenik meg. Ez megkönnyíti a bonyolult nevű témák azonosítását.",
"display_name_dialog_placeholder": "Megjelenítendő név",
"reserve_dialog_checkbox_label": "Téma lefoglalása és a hozzáférés beállítása",
"publish_dialog_call_label": "Telefonhívás",
"publish_dialog_call_item": "Hívja a{{number}}telefonszámot",
"publish_dialog_call_reset": "Hívás törlése",
"publish_dialog_chip_call_label": "Telefonhívás",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Nincs ellenőrzött telefonszám",
"publish_dialog_checkbox_markdown": "Markdown formátum",
"subscribe_dialog_subscribe_use_another_background_info": "A webalkalmazás bezárása esetén a többi szerverről érkező értesítések nem érkeznek meg",
"subscribe_dialog_subscribe_button_generate_topic_name": "Név generálása",
"subscribe_dialog_error_topic_already_reserved": "A téma már lefoglalva",
"account_basics_title": "Fiók",
"account_basics_username_title": "Felhasználónév",
"account_basics_username_description": "Hé, ez te vagy ❤",
"account_basics_username_admin_tooltip": "Ön rendszergazda",
"account_basics_password_title": "Jelszó",
"account_basics_password_description": "Fiókjának jelszavának módosítása",
"account_basics_password_dialog_title": "Jelszó módosítása",
"account_basics_password_dialog_current_password_label": "Jelenlegi jelszó",
"account_basics_password_dialog_new_password_label": "Új jelszó",
"account_basics_password_dialog_confirm_password_label": "Jelszó megerősítése",
"account_basics_password_dialog_button_submit": "Jelszó módosítása",
"account_basics_password_dialog_current_password_incorrect": "Helytelen jelszó",
"account_basics_phone_numbers_title": "Telefonszámok",
"account_basics_phone_numbers_dialog_description": "A hívásértesítési funkció használatához legalább egy telefonszámot hozzá kell adnia és igazolnia kell. Az igazolás SMS-ben vagy telefonhívás útján történhet.",
"account_basics_phone_numbers_description": "Telefonos értesítések esetén",
"account_basics_phone_numbers_no_phone_numbers_yet": "Még nincs telefonszám",
"account_basics_phone_numbers_copied_to_clipboard": "A telefonszámot a vágólapra másoltam",
"account_basics_phone_numbers_dialog_title": "Telefonszám hozzáadása",
"account_basics_phone_numbers_dialog_number_label": "Telefonszám",
"account_basics_phone_numbers_dialog_number_placeholder": "pl. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "SMS küldése",
"account_basics_phone_numbers_dialog_verify_button_call": "Hívj fel",
"account_basics_phone_numbers_dialog_code_label": "Ellenőrző kód",
"account_basics_phone_numbers_dialog_code_placeholder": "pl. 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Kód megerősítése",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Hívás",
"account_basics_cannot_edit_or_delete_provisioned_user": "A hozzárendelt felhasználót nem lehet szerkeszteni vagy törölni",
"account_usage_title": "Használat",
"account_usage_of_limit": "a{{limit}}",
"account_usage_unlimited": "Korlátlan",
"account_usage_limits_reset_daily": "A használati korlátok minden nap éjfélkor (UTC) visszaállnak",
"account_basics_tier_title": "Számlatípus",
"account_basics_tier_description": "Fiókod erősségi szintje",
"account_basics_tier_admin": "Admin",
"account_basics_tier_admin_suffix_with_tier": "C(a{{tier}}szinttel)",
"account_basics_tier_admin_suffix_no_tier": "nincs szint",
"account_basics_tier_basic": "Alapvető",
"account_basics_tier_free": "Ingyenes",
"account_basics_tier_interval_monthly": "havonta",
"account_basics_tier_interval_yearly": "éves szinten",
"account_basics_tier_upgrade_button": "Frissíts Pro verzióra",
"account_basics_tier_change_button": "Változás",
"account_basics_tier_paid_until": "Az előfizetés{{date}}-ig fizetve, és automatikusan megújul",
"account_basics_tier_payment_overdue": "A fizetési határidő lejárt. Kérjük, frissítse a fizetési módját, ellenkező esetben fiókját hamarosan alacsonyabb szintre soroljuk át.",
"account_basics_tier_canceled_subscription": "Előfizetését töröltük, és{{date}}-tól ingyenes fiókra váltunk.",
"account_basics_tier_manage_billing_button": "Számlázás kezelése",
"account_usage_messages_title": "Közzétett üzenetek",
"account_usage_emails_title": "Elküldött e-mailek",
"account_usage_calls_title": "Kezdeményezett telefonhívások",
"account_usage_calls_none": "Ebből a fiókból nem lehet telefonálni",
"account_usage_reservations_title": "Fenntartott témák",
"account_usage_reservations_none": "Ehhez a fiókhoz nincs fenntartott téma",
"account_usage_attachment_storage_title": "Mellékletek tárolása",
"account_usage_attachment_storage_description": "{{filesize}}fájlonként, törlésre kerül{{expiry}}után",
"account_usage_basis_ip_description": "A fiók használati statisztikái és korlátai az Ön IP-címén alapulnak, ezért előfordulhat, hogy más felhasználókkal is megosztásra kerülnek. A fent feltüntetett korlátok a jelenlegi sávszélesség-korlátozások alapján számított hozzávetőleges értékek.",
"account_usage_cannot_create_portal_session": "A számlázási portál nem nyitható meg",
"account_delete_title": "Fiók törlése",
"account_delete_description": "Fiókjának végleges törlése",
"account_delete_dialog_description": "Ezzel véglegesen törlöd a fiókodat, beleértve a szerveren tárolt összes adatot is. A törlés után a felhasználóneved 7 napig nem lesz elérhető. Ha biztosan folytatni szeretnéd, kérjük, erősítsd meg a jelszavadat az alábbi mezőben.",
"account_delete_dialog_label": "Jelszó",
"account_delete_dialog_button_cancel": "Mégse",
"account_delete_dialog_button_submit": "Fiók végleges törlése",
"account_delete_dialog_billing_warning": "A fiók törlésével a fizetési előfizetés is azonnal megszűnik. Ezt követően már nem fogsz hozzáférni a fizetési irányítópanelhez.",
"account_upgrade_dialog_title": "Fiókcsomag módosítása",
"account_upgrade_dialog_interval_monthly": "Havi",
"account_upgrade_dialog_interval_yearly": "Évente",
"account_upgrade_dialog_interval_yearly_discount_save": "{{discount}}% mentése",
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "akár {{discount}}%-os megtakarítás",
"account_upgrade_dialog_cancel_warning": "Ezzel <strong>előfizetése megszűnik</strong>, és fiókja alacsonyabb szintre kerül {{date}}. Ezen a napon a témákhoz tartozó foglalások, valamint a szerveren tárolt üzenetek <strong>törlésre kerülnek</strong>.",
"account_upgrade_dialog_proration_info": "<strong>Arányos elszámolás</strong>: Fizetős csomagok közötti áttérés esetén az árkülönbözetet<strong>azonnal felszámítjuk</strong>. Alacsonyabb csomagra való áttérés esetén a fennmaradó egyenleget a jövőbeli számlázási időszakok fedezésére használjuk fel.",
"account_upgrade_dialog_reservations_warning_one": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt <strong>kérjük, törölj legalább egy foglalást</strong>. A foglalásokat a <Link>Beállítások</Link>menüpontban törölheted.",
"account_upgrade_dialog_reservations_warning_other": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt <strong>kérjük, törölj legalább {{count}} foglalást</strong>. A foglalásokat a <Link>Beállítások</Link>menüpontban törölheted.",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} fenntartott téma",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} fenntartott témák",
"account_upgrade_dialog_tier_features_no_reservations": "Nincsenek fenntartott témák",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} napi üzenet",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} napi üzenetek",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} napi hírlevél",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} napi e-mailek",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} napi telefonhívás",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} napi telefonhívás",
"account_upgrade_dialog_tier_features_no_calls": "Tilos telefonálni",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} fájlonként",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} teljes tárhely",
"account_upgrade_dialog_tier_price_per_month": "hónap",
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}}évente. Havonta számlázzuk.",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} éves számlázás. Megtakarítás: {{save}}.",
"account_upgrade_dialog_tier_selected_label": "Kiválasztott",
"account_upgrade_dialog_tier_current_label": "Jelenlegi",
"account_upgrade_dialog_billing_contact_email": "Számlázással kapcsolatos kérdéseivel kérjük, forduljon közvetlenül hozzánk.",
"account_upgrade_dialog_billing_contact_website": "Számlázással kapcsolatos kérdéseivel kérjük, keresse fel a<Link>weboldalunkat</Link>.",
"account_upgrade_dialog_button_cancel": "Mégse",
"account_upgrade_dialog_button_redirect_signup": "Regisztrálj most",
"account_upgrade_dialog_button_pay_now": "Fizessen most, és iratkozzon fel",
"account_upgrade_dialog_button_cancel_subscription": "Előfizetés lemondása",
"account_upgrade_dialog_button_update_subscription": "Előfizetés frissítése",
"account_tokens_title": "Hozzáférési tokenek",
"account_tokens_description": "Az ntfy API-n keresztül történő közzététel és feliratkozás során használjon hozzáférési tokeneket, így nem kell megadnia a fiókja bejelentkezési adatait. További információkért tekintse meg a <Link>dokumentációt</Link>.",
"account_tokens_table_token_header": "Token",
"account_tokens_table_label_header": "Címke",
"account_tokens_table_last_access_header": "Utolsó hozzáférés",
"account_tokens_table_expires_header": "Lejár",
"account_tokens_table_never_expires": "Soha nem jár le",
"account_tokens_table_current_session": "Aktuális böngészőmunkamenet",
"account_tokens_table_copied_to_clipboard": "Az hozzáférési token másolva",
"account_tokens_table_cannot_delete_or_edit": "A jelenlegi munkamenet-tokent nem lehet szerkeszteni vagy törölni",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "A létrehozott token nem szerkeszthető vagy törölhető",
"account_tokens_table_create_token_button": "Hozzon létre hozzáférési tokent",
"account_tokens_table_last_origin_tooltip": "A{{ip}}IP-címről kattintson a kereséshez",
"account_tokens_dialog_title_create": "Hozzon létre hozzáférési tokent",
"account_tokens_dialog_title_edit": "Hozzáférési token szerkesztése",
"account_tokens_dialog_title_delete": "Hozzáférési token törlése",
"account_tokens_dialog_label": "Címke, pl. Radarr értesítések",
"account_tokens_dialog_button_create": "Token létrehozása",
"account_tokens_dialog_button_update": "Token frissítése",
"account_tokens_dialog_button_cancel": "Mégse",
"account_tokens_dialog_expires_label": "Az hozzáférési token érvényessége",
"account_tokens_dialog_expires_unchanged": "A lejárati dátumot ne módosítsa",
"account_tokens_dialog_expires_x_hours": "A token érvényessége {{hours}} óra múlva lejár",
"account_tokens_dialog_expires_x_days": "A token érvényessége {{days}} nap múlva lejár",
"account_tokens_dialog_expires_never": "A token soha nem jár le",
"account_tokens_delete_dialog_title": "Hozzáférési token törlése",
"account_tokens_delete_dialog_description": "Mielőtt törölne egy hozzáférési tokent, győződjön meg arról, hogy egyetlen alkalmazás vagy szkript sem használja azt éppen. <strong>Ez a művelet visszafordíthatatlan</strong>.",
"account_tokens_delete_dialog_submit_button": "A token végleges törlése",
"prefs_notifications_web_push_title": "Háttérben futó értesítések",
"prefs_notifications_web_push_enabled_description": "Az értesítések akkor is beérkeznek, ha a webalkalmazás nem fut (Web Push segítségével)",
"prefs_notifications_web_push_disabled_description": "Értesítéseket kapunk, amikor a webalkalmazás fut (WebSocket-en keresztül)",
"prefs_notifications_web_push_enabled": "Engedélyezve a{{server}}esetében",
"prefs_notifications_web_push_disabled": "Fogyatékkal élők",
"prefs_users_description_no_sync": "A felhasználónevek és jelszavak nem kerülnek szinkronizálásra a fiókjával.",
"prefs_users_table_cannot_delete_or_edit": "A bejelentkezett felhasználót nem lehet törölni vagy szerkeszteni",
"prefs_appearance_theme_title": "Téma",
"prefs_system_default": "Rendszer alapértelmezett",
"prefs_appearance_theme_dark": "Sötét mód",
"prefs_appearance_theme_light": "Világos mód",
"prefs_reservations_title": "Fenntartott témák",
"prefs_reservations_description": "Itt foglalhat le témákat személyes használatra. A téma lefoglalásával tulajdonjogot szerez a témára, és megadhatja a többi felhasználó számára a témához való hozzáférési jogosultságokat.",
"prefs_reservations_limit_reached": "Elérted a fenntartott témák számának korlátját.",
"prefs_reservations_add_button": "Foglalt téma hozzáadása",
"prefs_reservations_edit_button": "Téma szerkesztése",
"prefs_reservations_delete_button": "A téma hozzáférésének visszaállítása",
"prefs_reservations_table": "Foglalt témák táblázata",
"prefs_reservations_table_topic_header": "Téma",
"prefs_reservations_table_access_header": "Hozzáférés",
"prefs_reservations_table_everyone_deny_all": "Csak én tudok hírleveleket kiadni és feliratkozni rájuk",
"prefs_reservations_table_everyone_read_only": "Én is közzétehetek és feliratkozhatok, mindenki feliratkozhat",
"prefs_reservations_table_everyone_write_only": "Én is közzétehetek és feliratkozhatok, mindenki közzétehet",
"prefs_reservations_table_everyone_read_write": "Bárki közzétehet és feliratkozhat",
"prefs_reservations_table_not_subscribed": "Nincs feliratkozva",
"prefs_reservations_table_click_to_subscribe": "Kattintson a feliratkozáshoz",
"prefs_reservations_dialog_title_add": "Téma elmentése",
"prefs_reservations_dialog_title_edit": "Foglalt téma szerkesztése",
"prefs_reservations_dialog_title_delete": "Témafoglalás törlése",
"prefs_reservations_dialog_description": "A téma lefoglalásával a téma tulajdonjogát szerezheti meg, és meghatározhatja a többi felhasználó hozzáférési jogosultságait a témához.",
"prefs_reservations_dialog_topic_label": "Téma",
"prefs_reservations_dialog_access_label": "Hozzáférés",
"reservation_delete_dialog_description": "A foglalás törlésével lemondasz a téma feletti tulajdonjogodról, és mások is lefoglalhatják azt. A meglévő üzeneteket és mellékleteket megtarthatod vagy törölheted.",
"reservation_delete_dialog_action_keep_title": "A gyorsítótárban tárolt üzenetek és mellékletek megőrzése",
"reservation_delete_dialog_action_keep_description": "A szerveren gyorsítótárba mentett üzenetek és mellékletek nyilvánosan láthatóvá válnak azok számára, akik ismerik a téma nevét.",
"reservation_delete_dialog_action_delete_title": "A gyorsítótárban tárolt üzenetek és mellékletek törlése",
"reservation_delete_dialog_action_delete_description": "A gyorsítótárban tárolt üzenetek és mellékletek véglegesen törlésre kerülnek. Ez a művelet visszafordíthatatlan.",
"reservation_delete_dialog_submit_button": "Foglalás törlése",
"error_boundary_button_reload_ntfy": "Töltsd be nekem",
"web_push_subscription_expiring_title": "Az értesítések felfüggesztésre kerülnek",
"web_push_subscription_expiring_body": "Nyissa meg az ntfy alkalmazást, hogy továbbra is értesítéseket kapjon",
"web_push_unknown_notification_title": "Ismeretlen értesítés érkezett a szerverről",
"web_push_unknown_notification_body": "Előfordulhat, hogy a webalkalmazás megnyitásával frissítenie kell az ntfy-t",
"common_close": "Bezár",
"common_refresh": "Frissítés",
"email_verify_progress_title": "E-mail címed ellenőrzése...",
"email_verify_success_title": "E-mail cím ellenőrizve",
"email_verify_success_description": "Az e-mail címedet ellenőriztük és hozzáadtuk a fiókodhoz.",
"email_verify_error_title": "Az ellenőrzés sikertelen",
"email_verify_error_description": "Ez az ellenőrző link érvénytelen vagy lejárt. Újat kérhet a fiókbeállításaiban.",
"email_verify_button_account": "Ugrás a fiókhoz",
"version_update_available_title": "Új verzió elérhető",
"version_update_available_description": "Az ntfy szerver frissült. Kérjük, frissítse az oldalt.",
"signup_form_email": "E-mail cím (opcionális, a fiók helyreállításához)",
"login_link_forgot_password": "Elfelejtett jelszó",
"reset_password_request_title": "Jelszó visszaállítása",
"reset_password_request_description": "Add meg a felhasználóneved vagy e-mail címed. Ha van fiókod, akkor e-mailben kapsz egy linket a jelszavad visszaállításához.",
"reset_password_request_primary_required": "Ez csak akkor működik, ha már hozzáadott egy elsődleges e-mail címet, és ellenőrizte azt.",
"reset_password_request_identifier_label": "Felhasználónév vagy e-mail cím",
"reset_password_request_button_submit": "Visszaállítási link küldése",
"reset_password_sent_title": "Ellenőrizd a beérkező leveleidet",
"reset_password_sent_description": "Ha létezik fiók, e-mailben elküldtük a jelszó visszaállításához szükséges linket.",
"reset_password_back_to_login": "Vissza a bejelentkezéshez",
"reset_password_disabled": "Jelszó-visszaállítás le van tiltva",
"reset_password_title": "Új jelszó beállítása",
"reset_password_form_password": "Új jelszó",
"reset_password_form_confirm": "Új jelszó megerősítése",
"reset_password_form_button_submit": "Jelszó beállítása",
"reset_password_form_error_invalid": "Ez a visszaállító link érvénytelen vagy lejárt. Kérjen újat.",
"reset_password_success_title": "Jelszó megváltozott",
"reset_password_success_description": "A jelszava megváltozott. Most már bejelentkezhet az új jelszavával.",
"action_bar_reload": "Alkalmazás újratöltése",
"account_basics_emails_title": "E-mail címek",
"account_basics_emails_description": "E-mail értesítésekért és jelszó-visszaállításért",
"account_basics_emails_no_emails_yet": "Még nincsenek e-mailek",
"account_basics_emails_copied_to_clipboard": "E-mail cím másolva a vágólapra",
"account_basics_emails_chip_actions_primary": "Elsődleges cím, amelyet alapértelmezett e-mail címként használunk. Kattintson a műveletekhez.",
"account_basics_emails_chip_actions_verified": "Elsődleges cím, amelyet alapértelmezett e-mail címként használunk. Kattintson a művelethez.",
"account_basics_emails_chip_actions_unverified": "Értesítésekhez használható. Kattintson a műveletekért.",
"account_basics_emails_unverified": "ellenőrizetlen",
"account_basics_emails_set_primary": "Beállítás elsődleges e-mail címként",
"account_basics_emails_delete": "Cím eltávolítása",
"account_basics_emails_resend": "Ellenőrző e-mail újraküldése",
"account_basics_emails_resent": "Megerősítő e-mail elküldve, ellenőrizze a beérkező leveleit",
"account_basics_emails_primary_elsewhere": "Ez az e-mail cím egy másik fiók elsődleges címként van használatban.",
"account_basics_emails_no_recovery_warning": "Adjon meg legalább egy e-mail címet, hogy biztosan vissza tudja állítani fiókját, ha elveszíti jelszavát.",
"account_basics_emails_no_primary_warning": "Adjon meg egy elsődleges e-mail címet, hogy biztosan vissza tudja állítani fiókját, ha elveszíti jelszavát.",
"account_basics_emails_dialog_title": "E-mail cím hozzáadása",
"account_basics_emails_dialog_description": "Add meg az e-mail címedet, hogy hozzáadhasd a fiókodhoz. Egy ellenőrző linket fogsz küldeni, hogy megerősítsd, hogy a címed a tiéd.",
"account_basics_emails_dialog_email_label": "Email cím",
"account_basics_emails_dialog_email_placeholder": "pl. user@example.com",
"account_basics_emails_dialog_verify_button": "Ellenőrző link küldése",
"account_basics_emails_dialog_check_inbox": "Ellenőrizd a beérkező leveleidet, és kattints a megerősítő linkre az e-mail cím megerősítéséhez. Amíg ezt nem teszed meg, addig ellenőrizetlenként fog megjelenni.",
"account_basics_tier_provisioned": "Kiépített",
"prefs_users_dialog_base_url_exists": "Már létezik egy felhasználó ehhez a szolgáltatás URL címéhez.",
"account_usage_emails_none": "Ezzel a fiókkal nem küldhetők e-mail értesítések",
"prefs_users_dialog_base_url_invalid": "érvénytelen URL-formátum. http:// vagy https:// előtaggal kell kezdődnie"
}
+58 -4
View File
@@ -237,7 +237,7 @@
"account_usage_limits_reset_daily": "Batasan penggunaan diatur ulang setiap hari di tengah malam (UTC)",
"account_basics_tier_title": "Jenis akun",
"account_basics_tier_description": "Tingkat daya akun Anda",
"account_basics_tier_admin_suffix_no_tier": "(tidak ada peringkat)",
"account_basics_tier_admin_suffix_no_tier": "tanpa tingkatan",
"account_basics_tier_basic": "Dasaran",
"account_basics_tier_change_button": "Ubah",
"account_basics_tier_paid_until": "Langganan dibayar sampai {{date}}, dan akan dibayar secara otomatis",
@@ -299,7 +299,7 @@
"prefs_reservations_dialog_title_edit": "Sunting reservasi topik",
"subscribe_dialog_subscribe_button_generate_topic_name": "Buat nama",
"account_basics_title": "Akun",
"account_basics_tier_admin_suffix_with_tier": "(dengan peringkat {{tier}})",
"account_basics_tier_admin_suffix_with_tier": "dengan tingkatan {{tier}}",
"account_basics_tier_free": "Gratis",
"account_tokens_dialog_expires_label": "Token akses kedaluwarsa dalam",
"account_basics_username_description": "Hei, itu Anda ❤",
@@ -399,12 +399,66 @@
"prefs_notifications_web_push_enabled": "Diaktifkan untuk {{server}}",
"prefs_notifications_web_push_disabled": "Dinonaktifkan",
"prefs_appearance_theme_dark": "Mode gelap",
"prefs_appearance_theme_system": "Sistem (bawaan)",
"prefs_system_default": "Sistem bawaan",
"prefs_appearance_theme_light": "Mode terang",
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Pengguna yang telah ditetapkan tidak dapat diedit atau dihapus",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan",
"common_close": "Tutup",
"common_refresh": "Segarkan",
"email_verify_progress_title": "Memverifikasi alamat email Anda...",
"email_verify_success_title": "Email telah diverifikasi",
"email_verify_success_description": "Alamat email Anda telah diverifikasi dan ditambahkan ke akun Anda.",
"email_verify_error_title": "Verifikasi gagal",
"email_verify_error_description": "Tautan verifikasi ini tidak valid atau sudah kedaluwarsa. Anda dapat meminta tautan baru dari pengaturan akun Anda.",
"email_verify_button_account": "Pergi ke akun",
"version_update_available_title": "Versi baru sudah tersedia",
"version_update_available_description": "Server ntfy telah diperbarui. Silakan segarkan halaman ini.",
"signup_form_email": "Email (opsional, untuk pemulihan akun)",
"login_link_forgot_password": "Lupa kata sandi",
"reset_password_request_title": "Atur ulang kata sandi",
"reset_password_request_description": "Masukkan nama pengguna atau alamat email Anda. Jika akun tersebut sudah ada, tautan untuk mengatur ulang kata sandi akan dikirimkan melalui email.",
"reset_password_request_primary_required": "Fitur ini hanya berfungsi jika Anda sudah menambahkan alamat email utama dan memverifikasinya.",
"reset_password_request_identifier_label": "Nama pengguna atau email",
"reset_password_request_button_submit": "Kirim tautan atur ulang",
"reset_password_sent_title": "Periksa kotak masuk Anda",
"reset_password_sent_description": "Jika akun tersebut sudah ada, tautan untuk mengatur ulang kata sandi Anda telah dikirimkan melalui email.",
"reset_password_back_to_login": "Kembali ke halaman masuk",
"reset_password_disabled": "Pengaturan ulang kata sandi dinonaktifkan",
"reset_password_title": "Atur kata sandi baru",
"reset_password_form_password": "Kata sandi baru",
"reset_password_form_confirm": "Konfirmasi kata sandi baru",
"reset_password_form_button_submit": "Atur kata sandi",
"reset_password_form_error_invalid": "Tautan pengaturan ulang ini tidak sah atau sudah kedaluwarsa. Silakan minta tautan baru.",
"reset_password_success_title": "Kata sandi diubah",
"reset_password_success_description": "Kata sandi Anda telah diubah. Sekarang Anda dapat masuk menggunakan kata sandi baru Anda.",
"action_bar_reload": "Muat ulang aplikasi",
"account_basics_emails_title": "Alamat email",
"account_basics_emails_description": "Untuk pemberitahuan melalui email dan pengaturan ulang kata sandi",
"account_basics_emails_no_emails_yet": "Belum ada email",
"account_basics_emails_copied_to_clipboard": "Alamat email telah disalin ke papan klip",
"account_basics_emails_chip_actions_primary": "Alamat utama, yang digunakan sebagai alamat email utama Anda. Klik untuk melakukan tindakan.",
"account_basics_emails_chip_actions_verified": "Dapat digunakan untuk pemberitahuan. Klik untuk melakukan tindakan.",
"account_basics_emails_chip_actions_unverified": "Alamat yang belum diverifikasi, silakan periksa kotak masuk Anda untuk melakukan verifikasi. Klik untuk melakukan tindakan.",
"account_basics_emails_unverified": "belum diverifikasi",
"account_basics_emails_set_primary": "Tetapkan sebagai email utama",
"account_basics_emails_delete": "Hapus alamat",
"account_basics_emails_resend": "Kirim ulang email verifikasi",
"account_basics_emails_resent": "Email verifikasi telah dikirim, silakan periksa kotak masuk Anda",
"account_basics_emails_primary_elsewhere": "Alamat email ini digunakan sebagai alamat utama pada akun lain",
"account_basics_emails_no_recovery_warning": "Tambahkan setidaknya satu alamat email agar Anda dapat memulihkan akun Anda jika lupa kata sandi.",
"account_basics_emails_no_primary_warning": "Tambahkan alamat email utama agar Anda dapat memulihkan akun Anda jika lupa kata sandi.",
"account_basics_emails_dialog_title": "Tambahkan alamat email",
"account_basics_emails_dialog_description": "Masukkan alamat email untuk menambahkannya ke akun Anda. Tautan verifikasi akan dikirimkan untuk memastikan bahwa alamat email tersebut memang milik Anda.",
"account_basics_emails_dialog_email_label": "Alamat email",
"account_basics_emails_dialog_email_placeholder": "misalnya user@example.com",
"account_basics_emails_dialog_verify_button": "Kirim tautan verifikasi",
"account_basics_emails_dialog_check_inbox": "Periksa kotak masuk Anda dan klik tautan verifikasi untuk mengonfirmasi alamat email ini. Alamat email tersebut akan tetap berstatus belum diverifikasi sampai Anda melakukannya.",
"account_basics_tier_provisioned": "Telah disediakan",
"account_usage_emails_none": "Tidak ada pemberitahuan email yang dapat dikirim melalui akun ini",
"prefs_users_dialog_base_url_invalid": "Format URL tidak sah. Harus diawali dengan http:// atau https://",
"prefs_users_dialog_base_url_exists": "Pengguna dengan URL layanan ini sudah ada"
}
+58 -4
View File
@@ -252,8 +252,8 @@
"account_basics_tier_title": "Tipo di account",
"account_basics_tier_description": "Permessi del tuo account",
"account_basics_tier_admin": "Amministratore",
"account_basics_tier_admin_suffix_with_tier": "(con livello {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(nessun livello)",
"account_basics_tier_admin_suffix_with_tier": "con livello {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "nessun livello",
"account_basics_tier_basic": "Base",
"account_basics_tier_free": "Gratuito",
"account_usage_emails_title": "Email inviate",
@@ -390,7 +390,7 @@
"prefs_notifications_web_push_disabled": "Disabilitato",
"prefs_users_table_cannot_delete_or_edit": "Impossibile eliminare o modificare l'utente registrato",
"prefs_appearance_theme_title": "Tema",
"prefs_appearance_theme_system": "Sistema (predefinito)",
"prefs_system_default": "Sistema predefinito",
"prefs_appearance_theme_dark": "Modalità scura",
"prefs_appearance_theme_light": "Modalità chiara",
"prefs_reservations_table_topic_header": "Argomento",
@@ -405,5 +405,59 @@
"account_tokens_dialog_expires_x_hours": "Il token scade tra {{hours}} ore",
"prefs_reservations_table": "Tabella argomenti riservati",
"account_basics_cannot_edit_or_delete_provisioned_user": "Un utente autorizzato non può essere modificato o eliminato",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossibile modificare o eliminare il token fornito"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossibile modificare o eliminare il token fornito",
"common_close": "Chiudi",
"common_refresh": "Aggiorna",
"email_verify_progress_title": "Verificando il tuo indirizzo di posta elettronica...",
"email_verify_success_title": "Indirizzo di posta elettronica verificato",
"email_verify_success_description": "Il tuo indirizzo di posta elettronica è stato verificato ed aggiunto al tuo profilo.",
"email_verify_error_title": "Verifica non riuscita",
"email_verify_error_description": "Questo indirizzo di verifica è invalido o scaduto. Puoi richiederne un'altro dalle impostazioni del profilo.",
"email_verify_button_account": "Vai al tuo profilo",
"version_update_available_title": "Nuova versione disponibile",
"version_update_available_description": "Il server ntfy è stato aggiornato. Ricarica la pagina.",
"signup_form_email": "Indirizzo di posta elettronica (opzionale, per il recupero del profilo)",
"login_link_forgot_password": "Password dimenticata",
"reset_password_request_title": "Reimposta la password",
"reset_password_request_description": "Inserisci il tuo nome utente e indirizzo di posta elettronica. Se un profilo esiste, un indirizzo per reimpostare la password sarà inviato tramite posta elettronica.",
"reset_password_request_primary_required": "Questa procedura funziona solamente se hai già aggiunto un indirizzo di posta elettronica primario e lo hai verificato.",
"reset_password_request_identifier_label": "Nome utente o indirizzo di posta elettronica",
"reset_password_request_button_submit": "Invia collegamento per la reimpostazione della password",
"reset_password_sent_title": "Controlla la tua casella di posta elettronica in arrivo",
"reset_password_sent_description": "Se un profilo esiste, un indirizzo per reimpostare la password è stato inviato tramite posta elettronica.",
"reset_password_back_to_login": "Ritorna all'accesso",
"reset_password_disabled": "La reimpostazione della password è disabilitata",
"reset_password_title": "Imposta una nuova password",
"reset_password_form_password": "Nuova password",
"reset_password_form_confirm": "Conferma nuova password",
"reset_password_form_button_submit": "Imposta la password",
"reset_password_form_error_invalid": "Questo indirizzo di reimpostazione delle credenziali è invalido o scaduto. Richiederne uno nuovo.",
"reset_password_success_title": "Password modificata",
"reset_password_success_description": "La tua password è stata modificata. Ora puoi accedere con la tua nuova password.",
"action_bar_reload": "Ricarica app",
"account_basics_emails_title": "Indirizzo di posta elettronica",
"account_basics_emails_description": "Per notifiche tramite posta elettronica e reimpostazione della password",
"account_basics_emails_no_emails_yet": "Ancora nessun messaggio di posta elettronica",
"account_basics_emails_copied_to_clipboard": "Indirizzo di posta elettronica copiato negli appunti",
"account_basics_emails_chip_actions_primary": "Indirizzo primario, utilizzato come indirizzo di posta elettronica predefinito. Clicca per azioni.",
"account_basics_emails_chip_actions_verified": "Può essere usato per le notifiche. Clicca per azioni.",
"account_basics_emails_chip_actions_unverified": "Indirizzo non verificato, controlla la tua casella di posta elettronica in arrivo per verificare. Clicca per azioni.",
"account_basics_emails_unverified": "non verificato",
"account_basics_emails_set_primary": "Imposta come indirizzo di posta elettronica primario",
"account_basics_emails_delete": "Rimuovi indirizzo",
"account_basics_emails_resend": "Reinvia messaggio di posta elettronica di verifica",
"account_basics_emails_resent": "Messaggio di posta elettronica di verifica inviato, controlla la tua casella di posta in arrivo",
"account_basics_emails_primary_elsewhere": "L'indirizzo di posta elettronica è utilizzato come indirizzo primario su un profilo diverso",
"account_basics_emails_no_recovery_warning": "Aggiungi almeno un indirizzo di posta elettronica per assicurarti di poter recuperare il tuo profilo nel caso perdessi la tua password.",
"account_basics_emails_no_primary_warning": "Aggiungi un indirizzo di posta elettronica primario per assicurarti di poter recuperare il tuo profilo nel caso dimenticassi la tua password.",
"account_basics_emails_dialog_title": "Aggiungi un indirizzo di posta elettronica",
"account_basics_emails_dialog_description": "Inserisci un indirizzo di posta elettronica da aggiungere al tuo profilo. Un indirizzo di verifica ti verrà inviato per confermare che ti appartenga.",
"account_basics_emails_dialog_email_label": "Indirizzo di posta elettronica",
"account_basics_emails_dialog_email_placeholder": "p.es. utente@esempio.com",
"account_basics_emails_dialog_verify_button": "Invia indirizzo di verifica",
"account_basics_emails_dialog_check_inbox": "Controlla la tua casella di posta elettronica in arrivo e clicca sull'indirizzo di verifica per confermare questo indirizzo di posta elettronica. Apparirà come non verificato finchè questa procedura non verrà completata.",
"account_basics_tier_provisioned": "Predisposto",
"account_usage_emails_none": "Le notifiche tramite posta elettronica non possono essere inviate con questo profilo",
"prefs_users_dialog_base_url_invalid": "Formato dell'indirizzo invalido. Deve iniziare con http:// o https://",
"prefs_users_dialog_base_url_exists": "Un utente per questo indirizzo di servizio è già presente"
}
+58 -4
View File
@@ -226,7 +226,7 @@
"account_basics_title": "アカウント",
"account_basics_tier_description": "アカウントのパワーレベル",
"account_basics_tier_admin": "管理者",
"account_basics_tier_admin_suffix_with_tier": "(ティア {{tier}})",
"account_basics_tier_admin_suffix_with_tier": "ティア {{tier}}",
"account_basics_tier_free": "無料",
"account_usage_attachment_storage_description": "1ファイルあたり{{filesize}}、{{expiry}}を過ぎると削除",
"account_usage_basis_ip_description": "アカウントの使用量統計および制限はあなたのIPアドレスに基づいているため、他のユーザーと共有される可能性があります。上記制限は既存のレート制限に基づく概算値です。",
@@ -320,7 +320,7 @@
"account_basics_password_dialog_button_submit": "パスワードを変更",
"account_usage_title": "使用量",
"account_basics_tier_title": "アカウントタイプ",
"account_basics_tier_admin_suffix_no_tier": "(ティアなし)",
"account_basics_tier_admin_suffix_no_tier": "ティアなし",
"account_basics_tier_change_button": "変更",
"account_basics_tier_payment_overdue": "支払期限を過ぎています。支払い方法を更新しないと、近日中にアカウントはダウングレードされます。",
"account_basics_tier_canceled_subscription": "あなたのサブスクリプションはキャンセルされ{{date}}に無料アカウントにダウングレードされます。",
@@ -400,10 +400,64 @@
"prefs_notifications_web_push_enabled": "{{server}}で有効",
"prefs_notifications_web_push_disabled": "無効",
"prefs_appearance_theme_title": "テーマ",
"prefs_appearance_theme_system": "システム (既定)",
"prefs_system_default": "システム 既定",
"prefs_appearance_theme_dark": "ダークモード",
"web_push_unknown_notification_title": "不明な通知を受信しました",
"web_push_unknown_notification_body": "ウェブアプリを開いてntfyをアップデートする必要があります",
"account_basics_cannot_edit_or_delete_provisioned_user": "自動作成されたユーザーの編集や削除はできません",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "自動作成されたトークンは編集や削除はできません"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "自動作成されたトークンは編集や削除はできません",
"common_close": "閉じる",
"common_refresh": "更新",
"email_verify_progress_title": "メールアドレスを確認中…",
"email_verify_success_title": "メールアドレスを確認しました",
"email_verify_success_description": "メールアドレスが確認されアカウントに追加されました。",
"email_verify_error_title": "確認失敗",
"email_verify_error_description": "確認リンクが不正か期限切れです。アカウント設定から再度リクエストが可能です。",
"email_verify_button_account": "アカウント設定へ",
"version_update_available_title": "新しいバージョンが利用可能",
"version_update_available_description": "ntfyサーバーがアップデートされました。ページを更新して下さい。",
"signup_form_email": "メールアドレス(任意、アカウント復元用)",
"login_link_forgot_password": "パスワードを忘れた",
"reset_password_request_title": "パスワードをリセット",
"reset_password_request_description": "ユーザー名かメールアドレスを入力してください。アカウントが存在していれば、パスワードリセット用のリンクがメールで送信されます。",
"reset_password_request_primary_required": "この機能はプライマリメールアドレスが設定され確認されている場合にのみ使用できます。",
"reset_password_request_identifier_label": "ユーザー名またはメールアドレス",
"reset_password_request_button_submit": "リセット用リンクを送信する",
"reset_password_sent_title": "メールボックスを確認して下さい",
"reset_password_sent_description": "もしアカウントが存在していれば、パスワードリセット用のリンクがメールで送信されています。",
"reset_password_back_to_login": "サインイン画面に戻る",
"reset_password_disabled": "パスワードリセットは無効化されています",
"reset_password_title": "新しいパスワードを入力",
"reset_password_form_password": "新しいパスワード",
"reset_password_form_confirm": "新しいパスワードを確認",
"reset_password_form_button_submit": "パスワードを設定",
"reset_password_form_error_invalid": "このリセット用リンクは不正あるいは期限切れです。再度申請して下さい。",
"reset_password_success_title": "パスワードが変更されました",
"reset_password_success_description": "パスワードが変更されました。新しいパスワードでサインインして下さい。",
"action_bar_reload": "アプリをリロード",
"account_basics_emails_title": "メールアドレス",
"account_basics_emails_description": "メール通知とパスワードリセット用",
"account_basics_emails_no_emails_yet": "まだメールアドレスはありません",
"account_basics_emails_copied_to_clipboard": "メールアドレスをクリップボードにコピーしました",
"account_basics_emails_chip_actions_primary": "主アドレス、デフォルトのメールアドレスとして使用します。クリックして操作を表示。",
"account_basics_emails_chip_actions_verified": "通知として使えます。クリックして操作を表示。",
"account_basics_emails_chip_actions_unverified": "未確認のメールアドレス、メールボックスを確認してください。クリックして操作を表示。",
"account_basics_emails_unverified": "未確認",
"account_basics_emails_set_primary": "プライマリメールアドレスに設定",
"account_basics_emails_delete": "メールアドレスを削除",
"account_basics_emails_resend": "確認メールを再送信",
"account_basics_emails_resent": "確認メールを送信しました、メールボックスを確認してください",
"account_basics_emails_primary_elsewhere": "このメールアドレスは他のアカウントでプライマリアドレスとして使用されています",
"account_basics_emails_no_recovery_warning": "パスワードを忘れた時にアカウントを復活させられるように最低ひとつのメールアドレスを追加して下さい。",
"account_basics_emails_no_primary_warning": "パスワードを忘れた時にアカウントを復活させられるようにプライマリメールアドレスを追加して下さい。",
"account_basics_emails_dialog_title": "メールアドレスを追加",
"account_basics_emails_dialog_description": "メールアドレスを入力してアカウントに追加します。あなたのものである事を確認するために確認リンクが送信されます。",
"account_basics_emails_dialog_email_label": "メールアドレス",
"account_basics_emails_dialog_email_placeholder": "例: user@example.com",
"account_basics_emails_dialog_verify_button": "確認リンクを送信",
"account_basics_emails_dialog_check_inbox": "メールアドレスを確認するためにメールボックスの確認リンクをクリックして下さい。それまでは未確認として表示されます。",
"account_basics_tier_provisioned": "事前割り当て",
"account_usage_emails_none": "このアカウントではメール通知を送信できません",
"prefs_users_dialog_base_url_invalid": "URL形式が不正です。 http:// あるいは https:// から始まる必要があります",
"prefs_users_dialog_base_url_exists": "このサービスURLにはユーザーが既に存在します"
}
+1 -1
View File
@@ -266,7 +266,7 @@
"account_upgrade_dialog_button_cancel_subscription": "Avslutt abonnement",
"account_tokens_description": "Bruk tilgangstokener når du publiserer og abonnerer via ntfy-APIet, slik at du ikke trenger å sende innloggingsinformasjon for kontoen din. Se <Link>dokumentasjonen</Link> for å lære mer.",
"account_tokens_table_current_session": "Nåværende nettleserøkt",
"prefs_appearance_theme_system": "System (standard)",
"prefs_system_default": "System standard",
"prefs_notifications_web_push_disabled_description": "Varslinger mottas når web-appen kjører (via WebSocket)",
"prefs_appearance_theme_title": "Tema",
"prefs_appearance_theme_dark": "Mørk modus",
+1 -1
View File
@@ -397,7 +397,7 @@
"prefs_notifications_web_push_disabled": "Uitgezet",
"prefs_notifications_web_push_enabled_description": "Notificaties worden ontvangen, ook als de webapplicatie niet geopend is (via Web Push)",
"prefs_appearance_theme_title": "Thema",
"prefs_appearance_theme_system": "Systeem (standaard)",
"prefs_system_default": "Systeem standaard",
"prefs_appearance_theme_dark": "Donkere modus",
"prefs_appearance_theme_light": "Lichte modus",
"error_boundary_button_reload_ntfy": "Herlaad ntfy",

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