Compare commits

...
165 Commits
Author SHA1 Message Date
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
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
binwiederhier 19a7a14b1d Bump version 2026-06-04 14:44:15 -04:00
binwiederhier ddb878d985 Fix ACL case sensitivity issues in sqlite 2026-06-04 10:28:33 -04:00
binwiederhier 33e303272a Fix macOS build 2026-06-03 21:33:18 -04:00
Philipp C. Heckel 9ab1cf8918 Merge pull request #1770 from binwiederhier/dependabot/npm_and_yarn/web/multi-84120a5570
Bump react-router and react-router-dom in /web
2026-06-03 21:32:05 -04:00
Philipp C. Heckel b33f695dcd Merge pull request #1696 from ShipItAndPray/fix/darwin-serve-build
Include Darwin in the serve Unix build
2026-06-03 21:18:53 -04:00
dependabot[bot] e3e7d03f2c Bump react-router and react-router-dom in /web
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 6.30.4 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 6.30.3 to 6.30.4
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router@6.30.4/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@6.30.4/packages/react-router)

Updates `react-router-dom` from 6.30.3 to 6.30.4
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@6.30.4/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.30.4/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 6.30.4
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 6.30.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 01:04:59 +00:00
binwiederhier b4cb1bb7fb Fix race and a small issue with the access cache 2026-06-03 21:01:52 -04:00
binwiederhier d19617bb6b Release notes 2026-06-03 20:40:04 -04:00
binwiederhier cb0f977120 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-06-03 20:38:30 -04:00
binwiederhier 3e81620dac Bump 2026-06-03 20:38:19 -04:00
Philipp C. Heckel 30d0532811 Merge pull request #1748 from sskender/main
Add CLI version flag
2026-06-03 20:37:49 -04:00
Philipp C. Heckel 93d45eba6f Merge pull request #1467 from Velocifyer/patch-1
Add sandboxing to ntfy.service
2026-06-03 20:34:52 -04:00
binwiederhier 3eab9e0672 Release notes 2026-06-03 20:18:22 -04:00
Philipp C. Heckel 2bf5dd26eb Merge pull request #1769 from binwiederhier/access-cache-1
Access cache to optimize performance
2026-06-01 21:22:05 -04:00
binwiederhier 869f005136 Review 2026-06-01 21:15:43 -04:00
binwiederhier d2507dbeed Again: Manual review 2026-06-01 21:09:56 -04:00
binwiederhier ae27172f8d Add trace logs 2026-06-01 20:30:05 -04:00
binwiederhier e18329a17e Log changes 2026-06-01 14:08:33 -04:00
binwiederhier 104182a8be Log reload 2026-06-01 12:47:21 -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
binwiederhier 7614405332 Wire up server.yml 2026-05-31 21:50:57 -04:00
binwiederhier 4196e6444c Stringbuilder 2026-05-31 21:31:23 -04:00
binwiederhier a2dc290f31 Review 2026-05-31 21:25:53 -04:00
binwiederhier 0e2c459d6b Further review 2026-05-31 15:42:12 -04:00
binwiederhier 2f4afbdae5 Manual refinements 2026-05-31 15:26:28 -04:00
binwiederhier 204723f3c0 Make opt-in flag 2026-05-31 14:28:04 -04:00
binwiederhier 301be79f0a Per-user reload 2026-05-31 14:11:55 -04:00
binwiederhier 03d405ed80 Rename acl cahce 2026-05-31 11:42:54 -04:00
binwiederhier 4b87a27326 Docblock, more tests 2026-05-31 11:22:30 -04:00
binwiederhier d987796243 Rename 2026-05-31 11:08:30 -04:00
binwiederhier c841caa3b3 Review, rename to pattern 2026-05-31 11:05:27 -04:00
binwiederhier 6310e3a96f Replace atomic.Pointer for readbility 2026-05-31 10:45:10 -04:00
binwiederhier 62a812b742 Merge branch 'main' of github.com:binwiederhier/ntfy into access-cache-1 2026-05-31 10:19:44 -04:00
Philipp C. Heckel 5c5766b031 Merge pull request #1768 from nihalgonsalves/ng/pinact
security: pin GitHub Actions
2026-05-30 18:25:38 -04:00
Nihal Gonsalves 2d2b1635fe chore: group github action updates 2026-05-30 23:34:18 +02:00
Nihal Gonsalves c51a3c0cb1 security: pin GitHub Actions 2026-05-30 23:32:41 +02:00
binwiederhier 6481713626 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-05-30 13:23:17 -04:00
binwiederhier 561a44b29b Docs 2026-05-30 13:23:06 -04:00
Philipp C. Heckel edc504b47a Merge pull request #1720 from dmitrylyzo/fix-noreferrer
Fix opening links with noreferrer
2026-05-30 13:22:31 -04:00
Philipp C. Heckel 0635e1efdb Merge pull request #1759 from binwiederhier/dependabot/github_actions/actions/setup-go-6
Bump actions/setup-go from 4 to 6
2026-05-30 13:14:43 -04:00
Philipp C. Heckel 8831d2f87f Merge pull request #1741 from nexus-uw/patch-1
Add directories for attachment, mail, and s3 to Dockerfile-build
2026-05-30 13:14:09 -04:00
dependabot[bot] 2215479294 Bump actions/setup-go from 4 to 6
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 17:11:55 +00:00
Philipp C. Heckel aab944fc39 Merge pull request #1763 from binwiederhier/dependabot/github_actions/actions/setup-node-6
Bump actions/setup-node from 3 to 6
2026-05-30 13:11:47 -04:00
Philipp C. Heckel a829d1a4e1 Merge pull request #1760 from binwiederhier/dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 2 to 4
2026-05-30 13:10:51 -04:00
Philipp C. Heckel 965942844a Merge pull request #1761 from binwiederhier/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 3 to 6
2026-05-30 13:10:01 -04:00
dependabot[bot] 4dd7d1cd62 Bump actions/setup-node from 3 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 17:09:50 +00:00
Philipp C. Heckel f22f913590 Merge pull request #1754 from rubixvi/patch-1
Add new blog and forum posts for ntfy integration
2026-05-30 13:08:39 -04:00
binwiederhier f55886e3cc Pipeline fixes 2026-05-30 13:07:41 -04:00
dependabot[bot] b908213f02 Bump actions/checkout from 3 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 6.
- [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/v3...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 16:56:24 +00:00
dependabot[bot] 6596551bc1 Bump docker/login-action from 2 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 16:56:19 +00:00
binwiederhier 5d6b864130 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-05-30 12:55:34 -04:00
binwiederhier 36ab5b3a8b Fmt 2026-05-30 12:55:22 -04:00
binwiederhier 9f217d9d40 Bump 2026-05-30 12:55:14 -04:00
Philipp C. Heckel 360b40ec07 Merge pull request #1758 from simoneferrari/patch-1
docs: add ntfy Desktop (Windows) to CLIs + GUIs
2026-05-30 12:51:31 -04:00
binwiederhier a3f0f6cfa0 Dependabot protection 2026-05-30 12:44:38 -04:00
binwiederhier 0c819a003d Release notes 2026-05-30 12:35:51 -04:00
binwiederhier ef0dde8aa4 PWA token extend 2026-05-28 21:58:01 -04:00
binwiederhier 85abd40658 Merge branch 'main' of github.com:binwiederhier/ntfy into ng/pwa-token-extend 2026-05-28 20:51:14 -04:00
s1m0 fda636fe34 Update integrations.md with addtional ntfy GUI 2026-05-29 00:24:43 +03:00
binwiederhier 25520c4505 WIP: Access cache 2026-05-28 17:13:14 -04: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
Vincent Vu 6796f6147b Fix date for WHM push alerts blog post
Updated the date for the blog post on WHM push alerts.
2026-05-26 17:44:29 +10:00
Vincent Vu 134c4dd079 Add new blog and forum posts for ntfy integration 2026-05-26 17:30:58 +10:00
binwiederhier 47044c632e iOS release notes 2026-05-20 15:26:15 -04:00
Sven Skender b1008a78c4 Add CLI version flag
Refs #1722
2026-05-19 13:04:59 +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
𝕍𝕖𝕝𝕠𝕔𝕚𝕗𝕪𝕖𝕣 726b9d2b2c Add sandboxing to ntfy.service.
See [systemd.exec(5)](https://man.archlinux.org/man/systemd.exec.5) to find out what the options mean!
2026-05-18 14:33:48 -04:00
Simon Ramsay 9ccad9da2e Add directories for attachment, mail, and s3 to Dockerfile-build
Added new directories for attachment, mail, and s3 to the build process.

build was failing with 

>16.61     │ server/server.go:39:2: no required module provides package heckel.io/ntfy/v2/mail; to add it:
2026-05-15 23:09:56 -07:00
Dmitry Lyzo 6ba3b7c8be Fix opening links with noreferrer
rel="noreferrer" has the same effect as rel="noopener", but also
prevents the Referer header from being sent to the new page.
[https://mui.com/material-ui/react-link/#security]

If this feature is set, the browser will omit the Referer header,
as well as set noopener to true.
[https://developer.mozilla.org/en-US/docs/Web/API/Window/open#noreferrer]
2026-04-28 11:38:56 +03:00
ShipItAndPray 82e9dfe8f1 Include Darwin in the serve Unix build 2026-04-12 10:22:25 -05:00
Nihal Gonsalves 6a7c1c47aa fix: token extension for PWAs 2026-03-22 12:27:39 +01:00
Hunter Kehoe fb89b87efb do not escape JSON response 2025-12-07 14:15:36 -07:00
123 changed files with 9731 additions and 3022 deletions
+26
View File
@@ -0,0 +1,26 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: "weekly"
cooldown:
default-days: 7
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
groups:
all:
patterns:
- "*"
+4 -4
View File
@@ -8,13 +8,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.25.x'
go-version: '1.26.x'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@v3
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
-
name: Checkout docs pages code
uses: actions/checkout@v3
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io
+5 -5
View File
@@ -25,19 +25,19 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.25.x'
go-version: '1.26.x'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Docker login
uses: docker/login-action@v2
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
+4 -4
View File
@@ -25,13 +25,13 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.25.x'
go-version: '1.26.x'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
+3
View File
@@ -45,6 +45,9 @@ ADD ./db ./db
ADD ./message ./message
ADD ./model ./model
ADD ./webpush ./webpush
ADD ./attachment ./attachment
ADD ./mail ./mail
ADD ./s3 ./s3
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
FROM alpine
+22 -10
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,7 +87,7 @@ help:
# Building everything
clean: .PHONY
clean: FORCE
rm -rf dist build server/docs server/site
build: web docs cli
@@ -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)
@@ -151,7 +156,7 @@ web-deps:
# If this fails for .svg files, optimize them with svgo
web-deps-update:
cd web && $(NPM) update
cd web && $(NPM) update --before="$(shell date -d '7 days ago' +%Y-%m-%d)"
cd web && $(NPM) install
web-fmt:
@@ -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
@@ -269,13 +277,17 @@ check: test web-fmt-check fmt-check vet web-lint lint staticcheck
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
test: .PHONY
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:
@@ -307,7 +319,7 @@ lint:
which golint || go install golang.org/x/lint/golint@latest
go list ./... | grep -v /vendor/ | 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
+1 -1
View File
@@ -44,7 +44,7 @@ func New() *cli.App {
Name: "ntfy",
Usage: "Simple pub-sub notification service",
UsageText: "ntfy [OPTION..]",
HideVersion: true,
HideVersion: false,
UseShortOptionHandling: true,
Reader: os.Stdin,
Writer: os.Stdout,
+3
View File
@@ -52,6 +52,7 @@ var flagsServe = append(
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
@@ -168,6 +169,7 @@ func execServe(c *cli.Context) error {
authUsersRaw := c.StringSlice("auth-users")
authAccessRaw := c.StringSlice("auth-access")
authTokensRaw := c.StringSlice("auth-tokens")
authAccessCacheEnabled := c.Bool("auth-access-cache")
attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
@@ -468,6 +470,7 @@ func execServe(c *cli.Context) error {
conf.AuthUsers = authUsers
conf.AuthAccess = authAccess
conf.AuthTokens = authTokens
conf.AuthAccessCacheEnabled = authAccessCacheEnabled
conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build linux || dragonfly || freebsd || netbsd || openbsd
//go:build (darwin || linux || dragonfly || freebsd || netbsd || openbsd) && !noserver
package cmd
+89 -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)
}
@@ -378,6 +463,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AccessCacheEnabled: false, // Do not cache for CLI commands
}
if databaseURL != "" {
host, dbErr := pg.Open(databaseURL)
+68 -3
View File
@@ -1,14 +1,15 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/user"
"os"
"path/filepath"
"testing"
)
func TestCLI_User_Add(t *testing.T) {
@@ -121,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
@@ -128,6 +192,7 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
conf.File = configFile
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthDefault = user.PermissionDenyAll
conf.AuthAccessCacheEnabled = false
s, port = test.StartServerWithConfig(t, conf)
return
}
+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()
+9 -4
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.
@@ -2284,6 +2287,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). |
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `auth-access-cache` | `NTFY_AUTH_ACCESS_CACHE` | *bool* | false | Enables an in-memory ACL cache so authorization checks no longer hit the database. Only worth enabling on high-volume servers. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
@@ -2295,7 +2299,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
@@ -2392,6 +2396,7 @@ OPTIONS:
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--auth-access-cache, --auth_access_cache enables the in-memory ACL cache (high-volume servers only) (default: false) [$NTFY_AUTH_ACCESS_CACHE]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
+1
View File
@@ -255,6 +255,7 @@ Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-ser
go run main.go \
--log-level debug \
serve \
--base-url http://localhost \
--web-push-public-key KEY \
--web-push-private-key KEY \
--web-push-email-address <email> \
+38 -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.23.0/ntfy_2.23.0_linux_amd64.tar.gz
tar zxvf ntfy_2.23.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.23.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_amd64.tar.gz
tar zxvf ntfy_2.25.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.25.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv6.tar.gz
tar zxvf ntfy_2.23.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.23.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv6.tar.gz
tar zxvf ntfy_2.25.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.25.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv7.tar.gz
tar zxvf ntfy_2.23.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.23.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_armv7.tar.gz
tar zxvf ntfy_2.25.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.25.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_arm64.tar.gz
tar zxvf ntfy_2.23.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.23.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_linux_arm64.tar.gz
tar zxvf ntfy_2.25.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.25.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.25.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.23.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
sudo mv ntfy_2.23.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.23.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
sudo mv ntfy_2.23.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo mv ntfy_2.25.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.23.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.25.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
sudo mv ntfy_2.23.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.25.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.23.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.25.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
sudo mv ntfy_2.23.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo mv ntfy_2.25.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.23.0/ntfy_2.23.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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.23.0/ntfy_2.23.0_darwin_all.tar.gz > ntfy_2.23.0_darwin_all.tar.gz
tar zxvf ntfy_2.23.0_darwin_all.tar.gz
sudo cp -a ntfy_2.23.0_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.0_darwin_all.tar.gz > ntfy_2.25.0_darwin_all.tar.gz
tar zxvf ntfy_2.25.0_darwin_all.tar.gz
sudo cp -a ntfy_2.25.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.23.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.25.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.23.0/ntfy_2.23.0_windows_amd64.zip),
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.25.0/ntfy_2.25.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`
+3
View File
@@ -89,6 +89,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
@@ -188,9 +189,11 @@ 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
- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 5/2026
- [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025
- [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025
- [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025
+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)
+87 -10
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:
@@ -932,7 +970,7 @@ Here's an example of how it will look on Android:
</figure>
## Attachments
_Supported on:_ :material-android: :material-firefox:
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
@@ -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"
@@ -3213,8 +3255,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 +3711,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 +4147,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 +4216,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"
+104 -6
View File
@@ -4,14 +4,92 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Current stable releases
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.23.0 | May 17, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.6.0 | May 12, 2026 |
| Component | Version | Release date |
|------------------|---------|---------------|
| ntfy server | v2.25.0 | June 24, 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.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
* 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
on the production database. The topic authorization queries are consistently the highest ranking queries on the database,
so this will help quite a bit. The current database load is quite low, but I'm expecting it to increase as more users join
and use ntfy.
**Security issues:**
* Fix case-insensitive ACL topic matching on SQLite: an access control rule for `secret` no longer also matches a request for `SECRET`. SQLite's `LIKE` is case-insensitive for ASCII by default. PostgreSQL was unaffected. It's honestly incredible that this issue remained undetected for so long, especially while ntfy.sh was running on SQLite (it now runs on PostgreSQL).
**Features:**
* Add opt-in in-memory ACL cache (`auth-access-cache`) that serves topic authorization without a database round-trip; off by default, intended for high-volume servers
* Add `ntfy --version` flag to the CLI ([#1722](https://github.com/binwiederhier/ntfy/issues/1722), [#1748](https://github.com/binwiederhier/ntfy/pull/1748), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Saucy9607](https://github.com/Saucy9607) for reporting)
**Bug fixes + maintenance:**
* Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution)
* Fix `rel` attribute on auto-linked notification URLs so `noreferrer`/`noopener` are actually applied ([#1720](https://github.com/binwiederhier/ntfy/pull/1720), thanks to [@dmitrylyzo](https://github.com/dmitrylyzo) for the contribution)
* Add systemd sandboxing/hardening to the `ntfy.service` unit ([#1467](https://github.com/binwiederhier/ntfy/pull/1467), thanks to [@Velocifyer](https://github.com/Velocifyer) for the contribution)
* Fix `cmd` package build on macOS (darwin) so the server compiles from source ([#1631](https://github.com/binwiederhier/ntfy/issues/1631), [#1696](https://github.com/binwiederhier/ntfy/pull/1696), thanks to [@ShipItAndPray](https://github.com/ShipItAndPray) for the contribution, and [@XYenon](https://github.com/XYenon) for reporting)
## ntfy iOS app v1.7.0
Released May 30, 2026
This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS
feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner
and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification
row. There's also a new "Download attachments" setting to control auto-download by size.
**Features:**
* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
**Bug fixes + maintenance:**
* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
## ntfy server v2.23.0
Released May 17, 2026
@@ -1905,7 +1983,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
## ntfy Android v1.25.x (UNRELEASED)
### ntfy server v2.26.x (UNRELEASED)
**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
### ntfy Android v1.25.x (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.
@@ -1931,3 +2016,16 @@ especially when paired with increaseing the server-side `keepalive-interval` in
* 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)
### ntfy iOS app v1.8.0 (UNRELEASED)
**Features:**
* Deliver priority 5 (max/urgent) notifications as critical alerts that bypass silent mode and Do Not Disturb ([ntfy-ios#44](https://github.com/binwiederhier/ntfy-ios/pull/44), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Apply the attachment auto-download size setting to all attachment types, not just images ([ntfy-ios#43](https://github.com/binwiederhier/ntfy-ios/pull/43), thanks to [@am7590](https://github.com/am7590) for the contribution)
**Bug fixes + maintenance:**
* Restore the native swipe-to-go-back gesture in the topic detail view ([ntfy-ios#45](https://github.com/binwiederhier/ntfy-ios/pull/45), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Fix saved attachments being left "in use" so they couldn't be deleted in the Files app ([ntfy-ios#43](https://github.com/binwiederhier/ntfy-ios/pull/43), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Improve poll request subscription matching for protected topics so notifications resolve to the real message content, with better logging ([ntfy-ios#43](https://github.com/binwiederhier/ntfy-ios/pull/43), thanks to [@am7590](https://github.com/am7590) for the contribution)
+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();
}
})();
+29 -30
View File
@@ -1,25 +1,25 @@
module heckel.io/ntfy/v2
go 1.25.0
go 1.25.8
require (
cloud.google.com/go/firestore v1.22.0 // indirect
cloud.google.com/go/storage v1.62.1 // indirect
cloud.google.com/go/storage v1.62.3 // 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.51.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.279.0
google.golang.org/api v0.286.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -30,12 +30,12 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.20.0
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.9.2
github.com/jackc/pgx/v5 v5.10.0
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.44.0
golang.org/x/text v0.37.0
golang.org/x/sys v0.46.0
golang.org/x/text v0.38.0
)
require (
@@ -48,9 +48,9 @@ require (
cloud.google.com/go/longrunning v1.0.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.15 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // 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,27 +79,26 @@ 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.67.5 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.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
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/net v0.54.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
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.56.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+60 -58
View File
@@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
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.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8=
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
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.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
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/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=
@@ -108,8 +108,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -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.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
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=
@@ -165,24 +165,26 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
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/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
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=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
@@ -193,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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
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=
@@ -209,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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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=
@@ -220,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=
@@ -234,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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.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=
@@ -245,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=
@@ -258,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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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=
@@ -272,16 +274,16 @@ 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.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
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-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs=
google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/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/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+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"
+5 -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
@@ -116,6 +116,8 @@ type Config struct {
AuthTokens map[string][]*user.Token
AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
AuthAccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
AttachmentCacheDir string
AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64
@@ -223,6 +225,8 @@ func NewConfig() *Config {
AuthDefault: user.PermissionReadWrite,
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled,
AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval,
AttachmentCacheDir: "",
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
+4 -2
View File
@@ -143,9 +143,10 @@ var (
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 +157,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 +167,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
+12
View File
@@ -10,6 +10,18 @@ ExecReload=/bin/kill --signal HUP $MAINPID
Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE
LimitNOFILE=10000
PrivateDevices=true
ProtectClock=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
RestrictRealtime=true
ProtectHostname=true
# These will be added in a future update.
# ProtectSystem=full
# PrivateTmp=true
[Install]
WantedBy=multi-user.target
+80 -57
View File
@@ -57,8 +57,7 @@ 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
@@ -91,10 +90,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 +121,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"
@@ -176,16 +185,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 != "" {
@@ -247,16 +255,18 @@ func New(conf *Config) (*Server, error) {
var userManager *user.Manager
if conf.AuthFile != "" || pool != nil {
authConfig := &user.Config{
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
AccessCacheEnabled: conf.AuthAccessCacheEnabled,
AccessCacheReloadInterval: conf.AuthAccessCacheReloadInterval,
}
if pool != nil {
userManager, err = user.NewPostgresManager(pool, authConfig)
@@ -288,8 +298,7 @@ func New(conf *Config) (*Server, error) {
webPush: wp,
attachment: attachmentStore,
firebaseClient: firebaseClient,
smtpSender: mailer,
mailSender: mailSender,
mailer: sender,
topics: topics,
userManager: userManager,
messages: messages,
@@ -441,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()
}
@@ -540,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 {
@@ -549,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)
@@ -609,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 {
@@ -641,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)
@@ -657,17 +671,30 @@ 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 {
// 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) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
if unifiedpush {
@@ -676,8 +703,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 {
@@ -713,21 +739,21 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
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(),
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(),
}
}
@@ -944,7 +970,7 @@ 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 != "" {
@@ -1106,7 +1132,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
@@ -1206,7 +1232,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")
@@ -1504,7 +1530,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
@@ -1515,7 +1541,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 {
@@ -2379,10 +2405,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) {
+3
View File
@@ -116,6 +116,8 @@
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
# Use 'ntfy token generate' to generate a new access token.
# - auth-access-cache enables an in-memory snapshot of the access control table that authorizes every
# request without a database round-trip.
#
# Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
@@ -131,6 +133,7 @@
# auth-users:
# auth-access:
# auth-tokens:
# auth-access-cache: false
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
# the visitor IP address instead of the remote address of the connection.
+271 -57
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())
}
@@ -160,13 +175,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 {
@@ -615,83 +642,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 +899,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 +914,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)
})
}
+5 -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)
+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)
}
+1 -1
View File
@@ -105,7 +105,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)
+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)
})
}
+233 -24
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)
@@ -4026,6 +4139,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 +4226,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 +4460,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()
+46 -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"`
@@ -291,7 +317,7 @@ type apiAccountResponse struct {
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 +330,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 {
+278
View File
@@ -0,0 +1,278 @@
package user
import (
"regexp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
)
// accessCache is an in-memory index over the entire user_access table.
//
// exact[username][escapedTopic] returns the matching entry in O(1) for the common
// case where the requested topic appears verbatim in some rule. The key is the
// stored form of the topic (i.e. with \_ escapes), so Lookup escapes incoming
// topics through escapeUnderscore before probing.
//
// pattern[username] is the linear-scan list of %-bearing rules for that user.
// Walked per request; trivially small in practice. Wildcards are NOT u_everyone-
// only -- any user can create them.
type accessCache struct {
exact map[string]map[string]aclEntry
pattern map[string][]aclEntry
seq uint64 // Bumped on every reload; lets a full reload detect a per-user reload that raced its scan
mu sync.RWMutex // Protect exact, pattern, and seq
}
// testHookReloadScanned, if non-nil, is invoked by Reload after the DB scan but
// before the result is applied. Tests use it to inject a concurrent mutation
// into the full-reload race window; it is always nil in production.
var testHookReloadScanned func()
// aclEntry mirrors one user_access row. length feeds better()'s "longer
// pattern wins" tie-break; the stored topic/pattern string itself is not kept
// on the entry (the exact map already keys on it; surfacing wildcard "topics"
// like "up%" alongside real ones would invite misuse). pattern is the
// compiled regex form of the LIKE pattern; nil for exact entries.
type aclEntry struct {
length int
pattern *regexp.Regexp
read bool
write bool
}
func newAccessCache() *accessCache {
return &accessCache{
exact: make(map[string]map[string]aclEntry),
pattern: make(map[string][]aclEntry),
}
}
// Lookup returns the effective (read, write, found) permission for the given
// (username, topic), preserving the priority ordering of the original SQL query:
// 1. specific user beats Everyone
// 2. longer pattern beats shorter (more specific wins)
// 3. write beats read at equal length (write is "stronger")
func (c *accessCache) Lookup(username, topic string) (read, write, found bool) {
escapedTopic := escapeUnderscore(topic)
c.mu.RLock()
if username != Everyone {
if entry, found := c.lookupNoLock(username, topic, escapedTopic); found {
c.mu.RUnlock()
maybeLogACLDecision(username, username, topic, entry.read, entry.write)
return entry.read, entry.write, true
}
}
if entry, found := c.lookupNoLock(Everyone, topic, escapedTopic); found {
c.mu.RUnlock()
maybeLogACLDecision(username, Everyone, topic, entry.read, entry.write)
return entry.read, entry.write, true
}
c.mu.RUnlock()
maybeLogACLDecision(username, "", topic, false, false)
return false, false, false
}
// Reload scans (user_name, topic, read, write) rows and merges them into the
// cache. With no usernames the cache is replaced wholesale; otherwise the
// query is invoked with those usernames as positional args and only the
// listed users' slices are touched (a username absent from the result drops
// them from both maps). Runs against the primary so a reload after a
// mutation sees the just-written rows.
//
// Since Reload can be triggered from different places and for different scopes (full
// and user-specific), the function may cause races and lost-updates. This is solved
// with the sequence number.
func (c *accessCache) Reload(d *db.DB, query string, usernames ...string) error {
started := time.Now()
scope := "full"
if len(usernames) > 0 {
scope = "users=" + strings.Join(usernames, ",")
}
// Read the sequence number before the SQL query so we can detect races later
c.mu.RLock()
seqBefore := c.seq
c.mu.RUnlock()
args := make([]any, len(usernames))
for i, u := range usernames {
args[i] = u
}
// Query the database for all ACL entries
rows, err := d.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
exacts := make(map[string]map[string]aclEntry)
patterns := make(map[string][]aclEntry)
updatedEntries := 0
for rows.Next() {
var username, escapedTopic string
var read, write bool
if err := rows.Scan(&username, &escapedTopic, &read, &write); err != nil {
return err
}
entry, hasWildcard, err := toACLEntry(escapedTopic, read, write)
if err != nil {
return err
}
if hasWildcard {
patterns[username] = append(patterns[username], entry)
} else {
if exacts[username] == nil {
exacts[username] = make(map[string]aclEntry)
}
exacts[username][escapedTopic] = entry
}
updatedEntries++
}
if err := rows.Err(); err != nil {
return err
}
if testHookReloadScanned != nil {
testHookReloadScanned()
}
// Replace or update the internal maps
c.mu.Lock()
if len(usernames) == 0 {
if c.seq != seqBefore {
c.mu.Unlock()
log.Tag(tag).
Field("reload_scope", scope).
Field("duration_ms", time.Since(started).Milliseconds()).
Warn("ACL cache reload skipped due to race")
return nil
}
c.exact = exacts
c.pattern = patterns
} else {
for _, u := range usernames {
if e, ok := exacts[u]; ok {
c.exact[u] = e
} else {
delete(c.exact, u)
}
if p, ok := patterns[u]; ok {
c.pattern[u] = p
} else {
delete(c.pattern, u)
}
}
}
c.seq++
c.mu.Unlock()
log.Tag(tag).
Field("reload_scope", scope).
Field("updated_entries", updatedEntries).
Field("duration_ms", time.Since(started).Milliseconds()).
Debug("ACL cache reloaded")
return nil
}
// lookupNoLock returns the highest-priority entry for a single user. When
// more than one of that user's rules matches the requested topic, the winner
// is chosen by:
//
// 1. longer stored pattern beats shorter (a more specific rule wins over a
// more general one)
// 2. at equal length, write beats read (a stronger permission wins the tie)
//
// Exact and wildcard rules are ranked together under the same criteria, so
// an exact "foo" (length 3) beats a wildcard "f%" (length 2), but a wildcard
// "foo%" (length 4) beats an exact "foo" (length 3).
func (c *accessCache) lookupNoLock(username, topic, escapedTopic string) (*aclEntry, bool) {
var best aclEntry
var found bool
if exact, exists := c.exact[username]; exists {
if entry, exists := exact[escapedTopic]; exists {
best, found = entry, true
}
}
for _, pattern := range c.pattern[username] {
if !pattern.pattern.MatchString(topic) {
continue
} else if !found || better(pattern, best) {
best, found = pattern, true
}
}
return &best, found
}
// toACLEntry builds an aclEntry from one user_access row's values. The
// isWildcard return tells the caller which storage slot the entry belongs in:
// the per-user wildcard slice if true, the per-user exact map if false.
// Wildcards have their LIKE pattern pre-compiled into entry.pattern; exact
// entries leave entry.pattern nil.
func toACLEntry(escapedTopic string, read, write bool) (entry aclEntry, hasWildcard bool, err error) {
entry = aclEntry{
length: len(escapedTopic),
read: read,
write: write,
}
if !strings.Contains(escapedTopic, "%") {
return entry, false, nil
}
pattern, err := compileLikeToRegex(escapedTopic)
if err != nil {
return entry, true, err
}
entry.pattern = pattern
return entry, true, nil
}
// better implements the (length DESC, write DESC) tie-break used by the original
// query's ORDER BY for entries owned by the same user.
func better(a, b aclEntry) bool {
if a.length != b.length {
return a.length > b.length
} else if a.write != b.write {
return a.write
}
return false
}
// compileLikeToRegex converts a stored ntfy LIKE pattern into an equivalent Go
// regexp. In ntfy's stored form, % is the only wildcard (translated from *) and
// \_ is a literal underscore; no other backslashes occur. Topics themselves are
// restricted to [A-Za-z0-9_-] (see AllowedTopic), so neither % nor stray
// backslashes appear in user-supplied input.
func compileLikeToRegex(pattern string) (*regexp.Regexp, error) {
var sb strings.Builder
sb.WriteString("^")
i := 0
for i < len(pattern) {
switch {
case pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '_':
sb.WriteString(regexp.QuoteMeta("_"))
i += 2
case pattern[i] == '%':
sb.WriteString(".*")
i++
default:
sb.WriteString(regexp.QuoteMeta(string(pattern[i])))
i++
}
}
sb.WriteString("$")
return regexp.Compile(sb.String())
}
// maybeLogACLDecision logs an ACL lookup result
func maybeLogACLDecision(requestUser, matchedUser, topic string, read, write bool) {
ev := log.Tag(tag).
Field("user_name", requestUser).
Field("topic", topic).
Field("read", read).
Field("write", write)
if !ev.IsTrace() {
return
}
if matchedUser == "" {
ev.Trace("ACL no match")
return
}
ev.Field("matched_user", matchedUser).Trace("ACL match")
}
+312
View File
@@ -0,0 +1,312 @@
package user
import (
"regexp"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// Cache-only unit tests. Integration with the Manager (loading from the DB,
// reload-after-mutation, end-to-end Authorize behavior) is covered by the
// existing TestStoreAuthorizeTopicAccess* tests in manager_test.go via
// forEachStoreBackend.
func TestCompileLikeToRegex_Exact(t *testing.T) {
r := mustCompileLikeToRegex(t, "foo")
require.True(t, r.MatchString("foo"))
require.False(t, r.MatchString("foox"))
require.False(t, r.MatchString("xfoo"))
}
func TestCompileLikeToRegex_TrailingPercent(t *testing.T) {
r := mustCompileLikeToRegex(t, "up%")
require.True(t, r.MatchString("up"))
require.True(t, r.MatchString("up123"))
require.False(t, r.MatchString("xup"))
}
func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) {
r := mustCompileLikeToRegex(t, "%test%")
require.True(t, r.MatchString("test"))
require.True(t, r.MatchString("mytest"))
require.True(t, r.MatchString("testxxx"))
require.True(t, r.MatchString("xtestx"))
require.False(t, r.MatchString("nope"))
}
func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) {
// "my\_topic" is the stored form of a literal "my_topic" -- the underscore
// must match itself, NOT act as a SQL one-character wildcard.
r := mustCompileLikeToRegex(t, `my\_topic`)
require.True(t, r.MatchString("my_topic"))
require.False(t, r.MatchString("myXtopic"))
require.False(t, r.MatchString("mytopic"))
}
func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) {
// "nz\_vip\_%" is the stored form of "nz_vip_*" -- literal "nz_vip_" prefix
// followed by any suffix.
r := mustCompileLikeToRegex(t, `nz\_vip\_%`)
require.True(t, r.MatchString("nz_vip_"))
require.True(t, r.MatchString("nz_vip_alpha"))
require.False(t, r.MatchString("nz_vipX"))
require.False(t, r.MatchString("nzvip_alpha"))
}
func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) {
// Topics in ntfy can include '-', which is benign, but make sure
// regex metacharacters in the pattern are escaped properly anyway.
r := mustCompileLikeToRegex(t, "foo-bar")
require.True(t, r.MatchString("foo-bar"))
require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class
}
func TestACLCache_LookupBeforeReload(t *testing.T) {
// A freshly-constructed cache has empty exact and wildcards maps. The
// cache treats this as "no rule found", which the caller resolves via
// DefaultAccess.
c := newAccessCache()
read, write, found := c.Lookup("phil", "mytopic")
require.False(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_ExactMatchHit(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "mytopic", read: true, write: true},
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_ExactMatchMiss(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "mytopic", read: true, write: true},
})
_, _, found := c.Lookup("phil", "othertopic")
require.False(t, found)
}
func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) {
// Stored as "my\_topic" (toSQLWildcard of "my_topic"). A literal underscore
// in the requested topic must match, while any other single char must not.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: `my\_topic`, read: true, write: false},
})
read, write, found := c.Lookup("phil", "my_topic")
require.True(t, found)
require.True(t, read)
require.False(t, write)
_, _, found = c.Lookup("phil", "myXtopic")
require.False(t, found)
}
func TestACLCache_WildcardMatch(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "up%", read: false, write: true},
})
read, write, found := c.Lookup("phil", "up42")
require.True(t, found)
require.False(t, read)
require.True(t, write)
}
func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: false},
{user: "phil", topic: "mytopic", read: false, write: false}, // deny-all for phil
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_SpecificUserBeatsEveryoneEvenWhenShorter(t *testing.T) {
// The SQL's "user_name DESC" sort key takes precedence over LENGTH(topic).
// Concretely: a specific user with a shorter matching rule still wins over
// Everyone with a longer matching rule.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_SpecificUserBeatsEveryoneRegardlessOfWrite(t *testing.T) {
// Same-length rules but conflicting permissions across user boundary: the
// specific user always wins, even if its permission set is weaker (or
// stronger, in either direction).
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open
{user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.True(t, read)
require.False(t, write)
}
func TestACLCache_AnonymousReadsEveryone(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "announcements", read: true, write: false},
})
read, write, found := c.Lookup(Everyone, "announcements")
require.True(t, found)
require.True(t, read)
require.False(t, write)
}
func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) {
// Both rules belong to the same user (Everyone). The more specific (longer)
// "mytopic%" should beat the catch-all "%".
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "%", read: true, write: false},
{user: Everyone, topic: "mytopic%", read: true, write: true},
})
read, write, found := c.Lookup(Everyone, "mytopicX")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_ExactBeatsShorterWildcardSameUser(t *testing.T) {
// Same user, two matching rules: exact "foo" (length 3) and wildcard "f%"
// (length 2). The longer one wins, which is the exact rule -- mirroring
// the SQL's "LENGTH(topic) DESC" tie-break. Crucially, the cache must seed
// "best" from the exact map probe before walking wildcards, otherwise a
// shorter wildcard could overwrite a longer exact.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "foo", read: true, write: true}, // exact, length 3
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_LongerWildcardBeatsExactSameUser(t *testing.T) {
// Same user, two matching rules: exact "foo" (length 3) and wildcard "foo%"
// (length 4). The wildcard wins on length DESC. Exercises the "swap best
// to wildcard when better() returns true" path.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all
{user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) {
// Two wildcard rules of identical length for the same user. The write rule
// should win the tie-break. The two-rows-with-same-topic shape is
// impossible via real upsert (pkey would conflict), so we inject the entries
// directly into the cache's wildcard slice.
c := newAccessCache()
c.mu.Lock()
c.exact = map[string]map[string]aclEntry{}
c.pattern = map[string][]aclEntry{
Everyone: {
{length: len("ab%"), read: true, write: false, pattern: mustCompileLikeToRegex(t, "ab%")},
{length: len("ab%"), read: false, write: true, pattern: mustCompileLikeToRegex(t, "ab%")},
},
}
c.mu.Unlock()
_, write, found := c.Lookup(Everyone, "abc")
require.True(t, found)
require.True(t, write)
}
func TestACLCache_ConcurrentLookupAndReload(t *testing.T) {
// Lock-based swap must be safe under concurrent reads. The race detector
// catches any unsafe shared mutation.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: true},
})
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for !stop.Load() {
_, _, _ = c.Lookup(Everyone, "mytopic")
}
}()
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: i%2 == 0, write: i%2 == 1},
})
}
stop.Store(true)
}()
wg.Wait()
}
// rawACLRow models the rows that reload would Scan from the DB but avoids
// actually opening a DB for these unit tests.
type rawACLRow struct {
user string
topic string
read bool
write bool
}
// loadCache writes the given rows into the cache under its write lock,
// preserving the same exact/wildcard partitioning that reload would produce.
func loadCache(t *testing.T, c *accessCache, rows []rawACLRow) {
t.Helper()
exact := make(map[string]map[string]aclEntry)
wildcards := make(map[string][]aclEntry)
for _, r := range rows {
e := aclEntry{length: len(r.topic), read: r.read, write: r.write}
if strings.Contains(r.topic, "%") {
e.pattern = mustCompileLikeToRegex(t, r.topic)
wildcards[r.user] = append(wildcards[r.user], e)
} else {
if exact[r.user] == nil {
exact[r.user] = make(map[string]aclEntry)
}
exact[r.user][r.topic] = e
}
}
c.mu.Lock()
c.exact = exact
c.pattern = wildcards
c.mu.Unlock()
}
func mustCompileLikeToRegex(t *testing.T, pattern string) *regexp.Regexp {
t.Helper()
r, err := compileLikeToRegex(pattern)
require.NoError(t, err)
return r
}
+485 -44
View File
@@ -2,6 +2,7 @@
package user
import (
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
@@ -38,6 +39,9 @@ const (
const (
DefaultUserStatsQueueWriterInterval = 33 * time.Second
DefaultUserPasswordBcryptCost = 10
DefaultAccessCacheEnabled = false
DefaultAccessCacheReloadInterval = 87 * time.Second
DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept
)
var (
@@ -48,12 +52,14 @@ var (
// Manager handles user authentication, authorization, and management
type Manager struct {
config *Config
db *db.DB
queries queries
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
mu sync.Mutex
config *Config
db *db.DB
queries queries
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
accessCache *accessCache // In-memory snapshot of user_access; refreshed by maybeReloadAccessCache after every ACL mutation
quit chan struct{} // Closed by Close() to signal background goroutines to stop
mu sync.Mutex
}
var _ Auther = (*Manager)(nil)
@@ -65,20 +71,88 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
if config.QueueWriterInterval.Seconds() <= 0 {
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
}
if config.AccessCacheReloadInterval <= 0 {
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
}
if config.ExpiredMagicLinkReapInterval <= 0 {
config.ExpiredMagicLinkReapInterval = DefaultExpiredMagicLinkReapInterval
}
manager := &Manager{
config: config,
db: d,
statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate),
quit: make(chan struct{}),
queries: queries,
}
if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {
return nil, err
}
go manager.asyncQueueWriter(manager.config.QueueWriterInterval)
if config.AccessCacheEnabled {
manager.accessCache = newAccessCache()
if err := manager.maybeReloadAccessCache(); err != nil {
return nil, err
}
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
}
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
go manager.asyncExpiredMagicLinkReapLoop(manager.config.ExpiredMagicLinkReapInterval)
return manager, nil
}
// maybeReloadAccessCache refreshes the in-memory access cache from the
// primary database. No-op when the cache is disabled. With no usernames it
// does a full bulk reload; with one or more it refreshes only those users'
// slices in a single DB round-trip via an IN clause.
func (a *Manager) maybeReloadAccessCache(usernames ...string) error {
if a.accessCache == nil {
return nil
}
if len(usernames) == 0 {
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheAll)
}
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheUsers(len(usernames)), usernames...)
}
// asyncAccessCacheReloadLoop periodically bulk-reloads the access cache so that
// writes made by other processes against the same database (most notably the
// `ntfy access` CLI subcommand running while a server holds the cache) become
// visible within the configured interval. This Manager's own mutations do
// not depend on the poller -- they refresh affected users synchronously.
func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-a.quit:
return
case <-ticker.C:
if err := a.maybeReloadAccessCache(); err != nil {
log.Tag(tag).Err(err).Warn("Reloading ACL cache failed")
}
}
}
}
// 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.
@@ -151,9 +225,14 @@ func (a *Manager) RemoveUser(username string) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.removeUserTx(tx, username)
})
if err != nil {
return err
}
// Reload user-specific parts of the access cache
return a.maybeReloadAccessCache(username, Everyone)
}
// removeUserTx deletes the user with the given username
@@ -174,7 +253,7 @@ func (a *Manager) MarkUserRemoved(user *User) error {
if !AllowedUsername(user.Name) {
return ErrInvalidArgument
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
if err := a.resetUserAccessTx(tx, user.Name); err != nil {
return err
}
@@ -187,14 +266,27 @@ func (a *Manager) MarkUserRemoved(user *User) error {
}
return nil
})
if err != nil {
return err
}
// Reload user-specific parts of the access cache
return a.maybeReloadAccessCache(user.Name, Everyone)
}
// RemoveDeletedUsers deletes all users that have been marked deleted
func (a *Manager) RemoveDeletedUsers() error {
if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil {
res, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix())
if err != nil {
return err
}
return nil
affected, err := res.RowsAffected()
if err != nil {
return err
} else if affected == 0 {
return nil
}
// Full cache reload, because we don't know which users were affected.
return a.maybeReloadAccessCache()
}
// ChangePassword changes a user's password
@@ -225,9 +317,14 @@ func (a *Manager) ChangeRole(username string, role Role) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.changeRoleTx(tx, username, role)
})
if err != nil {
return err
}
// Full cache reload: Role changes are extremely rare.
return a.maybeReloadAccessCache()
}
// changeRoleTx changes a user's role
@@ -351,14 +448,20 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) {
a.statsQueue[userID] = stats
}
func (a *Manager) asyncQueueWriter(interval time.Duration) {
func (a *Manager) asyncQueueWriteLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
for range ticker.C {
if err := a.writeUserStatsQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
}
if err := a.writeTokenUpdateQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
defer ticker.Stop()
for {
select {
case <-a.quit:
return
case <-ticker.C:
if err := a.writeUserStatsQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
}
if err := a.writeTokenUpdateQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
}
}
}
}
@@ -416,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())
@@ -552,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
@@ -561,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
@@ -588,9 +711,14 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
// owner may either be a user (username), or the system (empty).
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.allowAccessTx(tx, username, topicPattern, permission, false)
})
if err != nil {
return err
}
// Only this user's row set changed; refresh their slice only.
return a.maybeReloadAccessCache(username)
}
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
@@ -606,9 +734,20 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
func (a *Manager) ResetAccess(username string, topicPattern string) error {
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.resetAccessTx(tx, username, topicPattern)
})
if err != nil {
return err
}
// Empty username -> deleteAllAccess affected every user, bulk reload.
// Otherwise refresh the named user plus Everyone, since resetUserAccessTx
// and deleteTopicAccess both touch rows owned by the user (typically the
// Everyone row from their reservations).
if username == "" {
return a.maybeReloadAccessCache()
}
return a.maybeReloadAccessCache(username, Everyone)
}
func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error {
@@ -650,10 +789,20 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// authorizeTopicAccess returns the read/write permissions for the given username and topic.
// The found return value indicates whether an ACL entry was found at all.
//
// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user.
// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*"
// - It also prioritizes write permissions over read permissions
// Priority:
// - Specific user beats Everyone
// - Longer pattern beats shorter (a more specific rule beats a more general one,
// e.g. "test*" > "*")
// - Write beats read at equal length
//
// When AccessCacheEnabled is true (config), the lookup is served entirely from
// the in-memory snapshot maintained by accessCache. Otherwise the original SQL
// query is executed against the database on every call.
func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) {
if a.accessCache != nil {
read, write, found = a.accessCache.Lookup(usernameOrEveryone, topic)
return read, write, found, nil
}
rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
if err != nil {
return false, false, false, err
@@ -731,7 +880,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) {
return ErrInvalidArgument
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
if limit > 0 {
hasReservation, err := a.hasReservationTx(tx, username, topic)
if err != nil {
@@ -755,6 +904,11 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
}
return nil
})
if err != nil {
return err
}
// Both user's and Everyone's rows changed.
return a.maybeReloadAccessCache(username, Everyone)
}
// RemoveReservations deletes the access control entries associated with the given username/topic,
@@ -769,7 +923,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
return ErrInvalidArgument
}
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
for _, topic := range topics {
if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
return err
@@ -777,11 +931,19 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
}
return nil
})
if err != nil {
return err
}
// Mirror the DB: rows for this user and any Everyone rows owned by this
// user are gone. Refresh both slices.
return a.maybeReloadAccessCache(username, Everyone)
}
// 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) {
@@ -872,7 +1034,7 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
// It returns the list of topics whose reservations were removed. The read and removal are
// performed atomically in a single transaction to avoid issues with stale replica data.
func (a *Manager) RemoveExcessReservations(username string, limit int64) ([]string, error) {
return db.QueryTx(a.db, func(tx *sql.Tx) ([]string, error) {
removedTopics, err := db.QueryTx(a.db, func(tx *sql.Tx) ([]string, error) {
reservations, err := a.reservationsTx(tx, username)
if err != nil {
return nil, err
@@ -890,6 +1052,17 @@ func (a *Manager) RemoveExcessReservations(username string, limit int64) ([]stri
}
return removedTopics, nil
})
if err != nil {
return nil, err
}
if len(removedTopics) > 0 {
// removeReservationAccessTx deletes rows owned by this user and the
// matching Everyone rows, so we refresh the access cache.
if err := a.maybeReloadAccessCache(username, Everyone); err != nil {
return nil, err
}
}
return removedTopics, nil
}
// otherAccessCount returns the number of access entries for the given topic that are not owned by the user
@@ -1033,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
}
@@ -1246,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
}
@@ -1294,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) {
@@ -1325,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
@@ -1515,8 +1950,14 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string,
return nil
}
// Close closes the underlying database
// Close stops background goroutines and closes the underlying database.
func (a *Manager) Close() error {
select {
case <-a.quit:
// Already closed
default:
close(a.quit)
}
return a.db.Close()
}
+54 -3
View File
@@ -1,6 +1,9 @@
package user
import (
"fmt"
"strings"
"heckel.io/ntfy/v2/db"
)
@@ -77,6 +80,11 @@ const (
WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\'
ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC
`
postgresSelectAccessCacheAllQuery = `
SELECT u.user_name, a.topic, a.read, a.write
FROM user_access a
JOIN "user" u ON u.id = a.user_id
`
postgresSelectUserAllAccessQuery = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
@@ -209,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 = `
@@ -221,6 +243,21 @@ const (
`
)
// postgresSelectAccessCacheUsersQuery builds the per-users cache-load query
// with a "$1, $2, ..." IN clause sized for n usernames.
func postgresSelectAccessCacheUsersQuery(n int) string {
var sb strings.Builder
sb.WriteString(`SELECT u.user_name, a.topic, a.read, a.write FROM user_access a JOIN "user" u ON u.id = a.user_id WHERE u.user_name IN (`)
for i := 0; i < n; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, "$%d", i+1)
}
sb.WriteString(")")
return sb.String()
}
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
var postgresQueries = queries{
selectUserByID: postgresSelectUserByIDQuery,
@@ -245,6 +282,8 @@ var postgresQueries = queries{
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
selectTopicPerms: postgresSelectTopicPermsQuery,
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
selectUserAllAccess: postgresSelectUserAllAccessQuery,
selectUserAccess: postgresSelectUserAccessQuery,
selectUserReservations: postgresSelectUserReservationsQuery,
@@ -281,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
+59 -4
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"path/filepath"
"strings"
_ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -83,6 +84,11 @@ const (
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\'
ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC
`
sqliteSelectAccessCacheAllQuery = `
SELECT u.user, a.topic, a.read, a.write
FROM user_access a
JOIN user u ON u.id = a.user_id
`
sqliteSelectUserAllAccessQuery = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
@@ -208,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 = `
@@ -220,6 +240,21 @@ const (
`
)
// sqliteSelectAccessCacheUsersQuery builds the per-users cache-load query
// with a "?, ?, ..." IN clause sized for n usernames.
func sqliteSelectAccessCacheUsersQuery(n int) string {
var sb strings.Builder
sb.WriteString(`SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id WHERE u.user IN (`)
for i := 0; i < n; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("?")
}
sb.WriteString(")")
return sb.String()
}
var sqliteQueries = queries{
selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery,
@@ -243,6 +278,8 @@ var sqliteQueries = queries{
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
selectTopicPerms: sqliteSelectTopicPermsQuery,
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
selectUserAccess: sqliteSelectUserAccessQuery,
selectUserReservations: sqliteSelectUserReservationsQuery,
@@ -279,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,
}
@@ -289,7 +338,13 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
}
d, err := sql.Open("sqlite3", filename)
// Open with case-sensitive LIKE. ACL topic matching is done via LIKE (see
// selectTopicPerms), and SQLite's LIKE is case-insensitive for ASCII by
// default -- without this, an ACL rule for "secret" would also match a
// request for "SECRET", which is a security iisue. PostgreSQL's LIKE is
// already case-sensitive, so this only affects SQLite. The pragma is
// applied to every pooled connection by the driver.
d, err := sql.Open("sqlite3", fmt.Sprintf("%s?_case_sensitive_like=on", filename))
if err != nil {
return nil, err
}
+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
})
}
+782 -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)
@@ -2169,6 +2177,300 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
})
}
// TestAuthorizeTopicAccess_CacheAndDirectDBAgree wires up two Managers on the
// same backend storage -- one with AccessCacheEnabled=true (in-memory cache
// path) and one with AccessCacheEnabled=false (direct SQL path) -- then runs
// an identical battery of authorizeTopicAccess queries against both and
// asserts byte-identical (read, write, found) responses for every query.
// This protects the in-memory implementation from drifting away from the
// SQL behavior it is meant to mirror.
func TestAuthorizeTopicAccess_CacheAndDirectDBAgree(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// Seed via a Manager with the cache enabled. Writes go to the shared
// backend; both Managers will see them after the writes commit.
writer := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
})
t.Cleanup(func() { writer.Close() })
require.Nil(t, writer.AddUser("phil", "mypass", RoleAdmin, false))
require.Nil(t, writer.AddUser("ben", "mypass", RoleUser, false))
require.Nil(t, writer.AddUser("alice", "mypass", RoleUser, false))
// A mix that exercises every branch of the priority logic:
// - exact and wildcard rules for the same user
// - exact and wildcard rules under Everyone
// - Everyone rules that are longer than the matching user rule
// - literal underscores (stored as "\_")
// - deny-all permissions
require.Nil(t, writer.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, writer.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, writer.AllowAccess("ben", "writeme", PermissionWrite))
require.Nil(t, writer.AllowAccess("ben", "ben_topic", PermissionReadWrite))
require.Nil(t, writer.AllowAccess("ben", "mytopic*", PermissionRead))
require.Nil(t, writer.AllowAccess("alice", "alice_*", PermissionWrite))
require.Nil(t, writer.AllowAccess("alice", "secret", PermissionDenyAll))
require.Nil(t, writer.AllowAccess(Everyone, "announcements", PermissionRead))
require.Nil(t, writer.AllowAccess(Everyone, "up*", PermissionWrite))
require.Nil(t, writer.AllowAccess(Everyone, "mytopic", PermissionDenyAll))
// Build a reader Manager with the cache OFF, pointing at the same backend.
reader := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: false,
})
t.Cleanup(func() { reader.Close() })
// Probe matrix: every (user, topic) pair that exercises some branch.
cases := []struct {
user, topic string
}{
// Anonymous reads.
{Everyone, "announcements"},
{Everyone, "up42"},
{Everyone, "up"},
{Everyone, "downstream"},
{Everyone, "mytopic"},
{Everyone, "nope"},
// Specific user, only-user rules.
{"ben", "mytopic"},
{"ben", "readme"},
{"ben", "writeme"},
{"ben", "ben_topic"},
{"ben", "benXtopic"}, // underscore in rule means "X" must NOT match
// Specific user falls through to Everyone.
{"ben", "announcements"},
{"ben", "up5"},
{"alice", "announcements"},
// Wildcards with literal underscores.
{"alice", "alice_anything"},
{"alice", "alice_"},
{"alice", "aliceX"}, // does NOT match alice_*
// Exact-vs-wildcard overlap for the same user (ben has both
// "mytopic" exact and "mytopic*" wildcard).
{"ben", "mytopic"}, // exact wins on length
{"ben", "mytopicX"}, // only wildcard matches
{"ben", "mytopicYZ"}, // only wildcard matches
// Deny-all override.
{"alice", "secret"},
// No matching rule anywhere.
{"ben", "completely_unmatched"},
{"alice", "completely_unmatched"},
{Everyone, "completely_unmatched"},
}
// Sanity: the two Managers must agree on every probe.
for _, tc := range cases {
cRead, cWrite, cFound, cErr := writer.authorizeTopicAccess(tc.user, tc.topic)
dRead, dWrite, dFound, dErr := reader.authorizeTopicAccess(tc.user, tc.topic)
require.Nil(t, cErr, "cache path errored for (%s, %s)", tc.user, tc.topic)
require.Nil(t, dErr, "direct-DB path errored for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dFound, cFound, "found mismatch for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dRead, cRead, "read mismatch for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dWrite, cWrite, "write mismatch for (%s, %s)", tc.user, tc.topic)
}
})
}
// TestAccessCacheReloadInterval_PicksUpExternalWrite proves that the
// background reloader actually closes the cross-process coherence gap: a
// write made through a *different* Manager on the same backend becomes
// visible to a cache-enabled Manager within roughly one reload interval,
// without that Manager being told about the write.
func TestAccessCacheReloadInterval_PicksUpExternalWrite(t *testing.T) {
const interval = 25 * time.Millisecond
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// reader holds the cache and polls; writer plays the role of an
// out-of-band process (e.g. `ntfy access` CLI) writing to the same
// backend.
reader := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
AccessCacheReloadInterval: interval,
})
t.Cleanup(func() { reader.Close() })
writer := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: false,
})
t.Cleanup(func() { writer.Close() })
require.Nil(t, writer.AddUser("phil", "mypass", RoleUser, false))
// Sanity: before the write, the reader sees no rule for this topic.
_, _, found, err := reader.authorizeTopicAccess("phil", "via-poller")
require.Nil(t, err)
require.False(t, found)
// Write through the second Manager. reader's cache is unaware.
require.Nil(t, writer.AllowAccess("phil", "via-poller", PermissionReadWrite))
// Wait for the poller to catch up. The interval is 25ms; allow a
// generous multiple to keep this test from flaking on slow CI.
require.Eventually(t, func() bool {
read, write, found, err := reader.authorizeTopicAccess("phil", "via-poller")
return err == nil && found && read && write
}, 2*time.Second, 10*time.Millisecond, "reader's cache never observed the external write")
})
}
// TestAccessCache_RemoveExcessReservationsInvalidatesCache models finding #1:
// RemoveExcessReservations deletes user_access rows but must also refresh the
// in-memory cache. Otherwise the owner keeps cached read/write access to a
// reservation that was removed (e.g. on a tier downgrade) until the next
// periodic reload -- and if another user re-reserves the freed topic in the
// meantime, the former owner can read/write the new owner's reserved topic.
func TestAccessCache_RemoveExcessReservationsInvalidatesCache(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// A deliberately long reload interval ensures the background poller
// cannot mask a missing synchronous invalidation: the mutation itself
// must refresh the cache.
a := newTestManagerFromConfig(t, newManager, &Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
AccessCacheReloadInterval: time.Hour,
})
require.Nil(t, a.AddUser("ben", "mypass", RoleUser, false))
require.Nil(t, a.AddReservation("ben", "topic1", PermissionDenyAll, 2))
require.Nil(t, a.AddReservation("ben", "topic2", PermissionDenyAll, 2))
// Both reservations grant ben full read/write; confirm the cache agrees.
for _, topic := range []string{"topic1", "topic2"} {
read, write, found, err := a.authorizeTopicAccess("ben", topic)
require.Nil(t, err)
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
// Downgrade ben to a single reservation; one topic is removed from the DB.
removed, err := a.RemoveExcessReservations("ben", 1)
require.Nil(t, err)
require.Len(t, removed, 1)
// The removed reservation's grant must be gone from the cache, not just
// from the database.
read, write, found, err := a.authorizeTopicAccess("ben", removed[0])
require.Nil(t, err)
require.False(t, found, "stale ACL for removed reservation %q still served from cache", removed[0])
require.False(t, read)
require.False(t, write)
// The surviving reservation must still be served from the cache.
survivor := "topic1"
if removed[0] == "topic1" {
survivor = "topic2"
}
read, write, found, err = a.authorizeTopicAccess("ben", survivor)
require.Nil(t, err)
require.True(t, found)
require.True(t, read)
require.True(t, write)
})
}
// TestAccessCache_FullReloadDoesNotClobberConcurrentRevoke models finding #2:
// a periodic full reload scans the whole user_access table outside the cache
// lock. If a local ACL mutation revokes a grant and refreshes that user's slice
// while the scan is in flight, applying the now-stale full snapshot must not
// resurrect the revoked grant. The testHookReloadScanned seam injects the revoke
// into exactly that race window.
func TestAccessCache_FullReloadDoesNotClobberConcurrentRevoke(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManagerFromConfig(t, newManager, &Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
AccessCacheReloadInterval: time.Hour, // keep the background poller out of this test
})
require.Nil(t, a.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, a.AllowAccess("phil", "secret", PermissionReadWrite))
// Sanity: the grant is served from the cache.
_, _, found, err := a.authorizeTopicAccess("phil", "secret")
require.Nil(t, err)
require.True(t, found)
// Arm the seam: when the full reload below finishes scanning (and still
// sees the grant), revoke it via a per-user reload before the full reload
// applies its now-stale snapshot. The re-entrant per-user reload that
// ResetAccess triggers is a no-op here (fired guard), and the whole thing
// runs single-threaded in this goroutine.
fired := false
testHookReloadScanned = func() {
if fired {
return
}
fired = true
require.Nil(t, a.ResetAccess("phil", "secret"))
}
defer func() { testHookReloadScanned = nil }()
// Trigger the full reload. Without the seq guard it would swap in its
// stale snapshot and resurrect the grant.
require.Nil(t, a.maybeReloadAccessCache())
_, _, found, err = a.authorizeTopicAccess("phil", "secret")
require.Nil(t, err)
require.False(t, found, "stale full reload resurrected a revoked grant")
})
}
// TestAuthorizeTopicAccess_TopicMatchingIsCaseSensitive guards against ACL
// topic matching being case-insensitive. SQLite's LIKE is case-insensitive for
// ASCII by default, which would let a request for "SECRET" match an ACL rule
// for "secret" -- a security hole. PostgreSQL's LIKE is already case-sensitive.
// NewSQLiteManager opens the database with case_sensitive_like enabled to close
// this gap. This exercises the direct-DB path (cache disabled), which is the
// path that runs the LIKE query; the in-memory cache is independently
// case-sensitive (Go map keys / case-sensitive regex).
func TestAuthorizeTopicAccess_TopicMatchingIsCaseSensitive(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: false, // exercise the direct-DB LIKE path
})
t.Cleanup(func() { a.Close() })
require.Nil(t, a.AddUser("ben", "mypass", RoleUser, false))
require.Nil(t, a.AllowAccess("ben", "secret", PermissionReadWrite)) // exact rule
require.Nil(t, a.AllowAccess("ben", "team*", PermissionReadWrite)) // wildcard rule, stored as "team%"
// The exact rule is honored verbatim.
read, write, found, err := a.authorizeTopicAccess("ben", "secret")
require.Nil(t, err)
require.True(t, found)
require.True(t, read)
require.True(t, write)
// Case variants of the exact rule must NOT match.
for _, topic := range []string{"SECRET", "Secret", "sEcReT"} {
_, _, found, err := a.authorizeTopicAccess("ben", topic)
require.Nil(t, err)
require.False(t, found, "ACL rule for \"secret\" must not match %q (case-insensitive match is a security hole)", topic)
}
// The wildcard rule is honored for the matching case.
_, _, found, err = a.authorizeTopicAccess("ben", "team-rocket")
require.Nil(t, err)
require.True(t, found)
// Case variants of the wildcard prefix must NOT match.
for _, topic := range []string{"TEAM-rocket", "Team-rocket", "TEAMING"} {
_, _, found, err := a.authorizeTopicAccess("ben", topic)
require.Nil(t, err)
require.False(t, found, "wildcard rule for \"team*\" must not match %q", topic)
}
})
}
func TestStoreReservations(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
@@ -2399,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)
})
}
@@ -2533,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)
}
+86 -14
View File
@@ -73,6 +73,27 @@ 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"`
@@ -161,6 +182,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,16 +296,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
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
@@ -273,6 +327,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")
)
@@ -303,7 +359,9 @@ type queries struct {
deleteUsersProvisioned string
// Access queries
selectTopicPerms string
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
selectAccessCacheAll string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache
selectAccessCacheUsers func(n int) string // Returns a per-users load query whose IN clause is sized for n usernames
selectUserAllAccess string
selectUserAccess string
selectUserReservations string
@@ -346,9 +404,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$"))
+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
+85 -1
View File
@@ -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>
+2000 -1508
View File
File diff suppressed because it is too large Load Diff
+14 -11
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-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.2.2",
"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",
@@ -40,8 +42,9 @@
"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"
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^4.1.9"
},
"browserslist": {
"production": [

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

+3 -1
View File
@@ -405,5 +405,7 @@
"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": "Презареждане"
}
+55 -1
View File
@@ -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"
}
+45 -12
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}}.",
@@ -418,7 +452,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",
+345 -163
View File
@@ -1,227 +1,409 @@
{
"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": "(a{{tier}}szinttel)",
"account_basics_tier_admin_suffix_no_tier": "(nincs besorolás)",
"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_appearance_theme_system": "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"
}
+32 -2
View File
@@ -215,7 +215,7 @@
"action_bar_reservation_add": "Reservar tópico",
"action_bar_sign_up": "Registar",
"nav_button_account": "Conta",
"common_copy_to_clipboard": "Copiar à área de transferência",
"common_copy_to_clipboard": "Copiar para a área de transferência",
"nav_upgrade_banner_label": "Upgrade para ntfy Pro",
"alert_not_supported_context_description": "As notificações são apenas suportadas através de HTTPS. Isto é uma limitação da <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_title": "Alterar o nome público",
@@ -406,5 +406,35 @@
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido"
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido",
"common_close": "Fechar",
"common_refresh": "Atualizar",
"email_verify_progress_title": "A verificar o email...",
"email_verify_success_title": "Email verificado",
"email_verify_success_description": "O seu email foi verificado e adicionado à sua conta.",
"email_verify_error_title": "Verificação falhada",
"email_verify_error_description": "Este link de verificação é inválido ou expirou. Pode pedir um novo nas definições da sua conta.",
"email_verify_button_account": "Ir para conta",
"version_update_available_title": "Nova sessão disponível",
"version_update_available_description": "O servidor ntfy foi atualizado. Por favor atualiza a página.",
"signup_form_email": "Email (opcional, para recuperação de conta)",
"login_link_forgot_password": "Esqueci a palavra-passe",
"reset_password_request_title": "Redefinir palavra-passe",
"reset_password_request_description": "Introduza o seu nome de utilizador ou email. Se a conta existir, um link de redefinição de palavra-passe será enviado para o email.",
"reset_password_request_primary_required": "Isto apenas funciona se adicionar e verificar um email principal.",
"reset_password_request_identifier_label": "Nome de utilizador ou email",
"reset_password_request_button_submit": "Enviar link de redefinição",
"reset_password_sent_title": "Verifique a caixa de entrada",
"reset_password_sent_description": "Se a conta existir, um link de redefinição de palavra-passe foi enviado.",
"reset_password_back_to_login": "Voltar a início de sessão",
"reset_password_disabled": "Redefinição de palavra-passe desativada",
"reset_password_title": "Definir uma nova palavra-passe",
"reset_password_form_password": "Nova palavra-passe",
"reset_password_form_confirm": "Confirme a nova palavra-passe",
"reset_password_form_button_submit": "Definir palavra-passe",
"reset_password_form_error_invalid": "O link de redefinição é invalido ou expirou. Por favor peça um novo.",
"reset_password_success_title": "Palavra-passe alterada",
"reset_password_success_description": "A sua palavra passe foi alterada. Pode agora iniciar sessão com a nova palavra passe.",
"action_bar_reload": "Reiniciar aplicação",
"account_basics_emails_title": "Endereços de email"
}
+1
View File
@@ -0,0 +1 @@
{}
+7 -7
View File
@@ -115,8 +115,8 @@
"subscribe_dialog_error_user_anonymous": "匿名",
"prefs_notifications_title": "通知",
"prefs_notifications_sound_title": "通知提示音",
"prefs_notifications_sound_description_none": "收到通知时不播放任何声音",
"prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音",
"prefs_notifications_sound_description_none": "收到通知时不播放任何提示音",
"prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 提示音",
"prefs_notifications_sound_no_sound": "静音",
"prefs_notifications_sound_play": "播放选中声音",
"prefs_notifications_min_priority_title": "最低优先级",
@@ -152,9 +152,9 @@
"prefs_appearance_title": "外观",
"prefs_appearance_language_title": "语言",
"prefs_appearance_theme_title": "主題",
"prefs_appearance_theme_system": "系統 (預設)",
"prefs_appearance_theme_dark": "黑暗模式",
"prefs_appearance_theme_light": "光亮模式",
"prefs_appearance_theme_system": "系统 (默认)",
"prefs_appearance_theme_dark": "深色模式",
"prefs_appearance_theme_light": "浅色模式",
"priority_min": "最低",
"priority_low": "低",
"priority_default": "默认",
@@ -247,8 +247,8 @@
"account_basics_tier_title": "账户类型",
"account_basics_tier_description": "您账户的权限级别",
"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_basic": "基础版",
"account_basics_tier_free": "免费",
"account_basics_tier_upgrade_button": "升级到专业版",
+207 -207
View File
@@ -1,34 +1,34 @@
{
"account_basics_password_description": "更改你的帳戶密碼",
"account_basics_password_dialog_button_submit": "更改密碼",
"account_basics_password_description": "變更帳戶密碼",
"account_basics_password_dialog_button_submit": "變更密碼",
"account_basics_password_dialog_confirm_password_label": "確認密碼",
"account_basics_password_dialog_current_password_incorrect": "密碼錯誤",
"account_basics_password_dialog_current_password_label": "當前密碼",
"account_basics_password_dialog_current_password_label": "目前密碼",
"account_basics_password_dialog_new_password_label": "新密碼",
"account_basics_password_dialog_title": "更改密碼",
"account_basics_password_dialog_title": "變更密碼",
"account_basics_password_title": "密碼",
"account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼板",
"account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼簿",
"account_basics_phone_numbers_description": "電話通知",
"account_basics_phone_numbers_dialog_channel_call": "撥打",
"account_basics_phone_numbers_dialog_channel_sms": "短信",
"account_basics_phone_numbers_dialog_channel_sms": "簡訊",
"account_basics_phone_numbers_dialog_check_verification_button": "確認碼",
"account_basics_phone_numbers_dialog_code_label": "驗證碼",
"account_basics_phone_numbers_dialog_code_placeholder": "例如:123456",
"account_basics_phone_numbers_dialog_description": "要使用來電通知功能,你需要新增並驗證至少一個電話號碼。可以通過短信或電話驗證。",
"account_basics_phone_numbers_dialog_description": "若要使用來電通知功能,請先新增並驗證至少一個電話號碼。你可以透過簡訊或電話完成驗證。",
"account_basics_phone_numbers_dialog_number_label": "電話號碼",
"account_basics_phone_numbers_dialog_number_placeholder": "例如:+1222333444",
"account_basics_phone_numbers_dialog_title": "新增電話號碼",
"account_basics_phone_numbers_dialog_verify_button_call": "撥打電話",
"account_basics_phone_numbers_dialog_verify_button_sms": "發送資訊",
"account_basics_phone_numbers_no_phone_numbers_yet": "無可執行的電話號碼",
"account_basics_phone_numbers_dialog_verify_button_sms": "傳送簡訊",
"account_basics_phone_numbers_no_phone_numbers_yet": "尚無電話號碼",
"account_basics_phone_numbers_title": "電話號碼",
"account_basics_tier_admin_suffix_no_tier": "(無等級)",
"account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等級)",
"account_basics_tier_admin": "管理員",
"account_basics_tier_basic": "基礎版",
"account_basics_tier_canceled_subscription": "你的訂閱已取消,並將在 {{date}} 降級為免費帳戶。",
"account_basics_tier_change_button": "改變",
"account_basics_tier_description": "你帳戶的權限級別",
"account_basics_tier_change_button": "變更",
"account_basics_tier_description": "你的帳戶權限等級",
"account_basics_tier_free": "免費",
"account_basics_tier_interval_monthly": "每月",
"account_basics_tier_interval_yearly": "每年",
@@ -40,149 +40,149 @@
"account_basics_title": "帳戶",
"account_basics_username_admin_tooltip": "你是管理員",
"account_basics_username_description": "嘿,那是你 ❤",
"account_basics_username_title": "用戶名",
"account_basics_username_title": "使用者名稱",
"account_delete_description": "永久刪除你的帳戶",
"account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再訪問計費儀錶板。",
"account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再存取計費儀錶板。",
"account_delete_dialog_button_cancel": "取消",
"account_delete_dialog_button_submit": "永久刪除帳戶",
"account_delete_dialog_description": "這將永久刪除你的帳戶,包括存儲在伺服器上的所有數據。刪除後,你的用戶名將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。",
"account_delete_dialog_description": "這將永久刪除你的帳戶,包括儲存在伺服器上的所有資料。刪除後,你的使用者名稱將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。",
"account_delete_dialog_label": "密碼",
"account_delete_title": "刪除帳戶",
"account_tokens_delete_dialog_description": "在刪除訪問令牌之前,請確保沒有應用程序或腳本正在活躍使用它。 <strong>此操作無法撤銷</strong>。",
"account_tokens_delete_dialog_submit_button": "永久删除令牌",
"account_tokens_delete_dialog_title": "刪除訪問令牌",
"account_tokens_description": "通過 ntfy API 發布和訂閱時使用訪問令牌,因此你不必發送你的帳戶憑證。查看<Link>文檔</Link>以了解更多資訊。",
"account_tokens_delete_dialog_description": "在刪除存取權杖之前,請確認沒有應用程式或指令碼正在使用此權杖。<strong>此操作無法復原</strong>。",
"account_tokens_delete_dialog_submit_button": "永久刪除權杖",
"account_tokens_delete_dialog_title": "刪除存取權杖",
"account_tokens_description": "透過 ntfy API 發布和訂閱時,請使用存取權杖,這樣就不必傳送你的帳戶憑證。請參閱<Link>說明</Link>以瞭解更多資訊。",
"account_tokens_dialog_button_cancel": "取消",
"account_tokens_dialog_button_create": "創建令牌",
"account_tokens_dialog_button_update": "更新令牌",
"account_tokens_dialog_expires_label": "訪問令牌過期於",
"account_tokens_dialog_expires_never": "令牌永不過期",
"account_tokens_dialog_button_create": "建立權杖",
"account_tokens_dialog_button_update": "更新權杖",
"account_tokens_dialog_expires_label": "存取權杖到期時間",
"account_tokens_dialog_expires_never": "權杖永不過期",
"account_tokens_dialog_expires_unchanged": "保持過期日期不變",
"account_tokens_dialog_expires_x_days": "令牌在 {{days}} 天後過期",
"account_tokens_dialog_expires_x_hours": "令牌在 {{hours}} 小時後過期",
"account_tokens_dialog_expires_x_days": "權杖將在 {{days}} 天後到期",
"account_tokens_dialog_expires_x_hours": "權杖將在 {{hours}} 小時後到期",
"account_tokens_dialog_label": "標籤,例如:Radarr 通知",
"account_tokens_dialog_title_create": "創建訪問令牌",
"account_tokens_dialog_title_delete": "刪除訪問令牌",
"account_tokens_dialog_title_edit": "編輯訪問令牌",
"account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除當前會話令牌",
"account_tokens_table_copied_to_clipboard": "已複製訪問令牌",
"account_tokens_table_create_token_button": "創建訪問令牌",
"account_tokens_table_current_session": "當前瀏覽器會話",
"account_tokens_dialog_title_create": "建立存取權杖",
"account_tokens_dialog_title_delete": "刪除存取權杖",
"account_tokens_dialog_title_edit": "編輯存取權杖",
"account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除目前的工作階段權杖",
"account_tokens_table_copied_to_clipboard": "已複製存取權杖",
"account_tokens_table_create_token_button": "建立存取權杖",
"account_tokens_table_current_session": "目前的瀏覽器工作階段",
"account_tokens_table_expires_header": "過期",
"account_tokens_table_label_header": "標籤",
"account_tokens_table_last_access_header": "最後訪問",
"account_tokens_table_last_origin_tooltip": "於IP地址 {{ip}},點擊查找",
"account_tokens_table_last_access_header": "最後存取",
"account_tokens_table_last_origin_tooltip": "來自 IP 位址 {{ip}},點選即可查詢",
"account_tokens_table_never_expires": "永不過期",
"account_tokens_table_token_header": "令牌",
"account_tokens_title": "訪問令牌",
"account_upgrade_dialog_billing_contact_email": "有關賬單問題,請直接<Link>聯繫我們 </Link>。",
"account_upgrade_dialog_billing_contact_website": "有關賬單問題,請參考我們的<Link>網站 </Link>。",
"account_tokens_table_token_header": "權杖",
"account_tokens_title": "存取權杖",
"account_upgrade_dialog_billing_contact_email": "如有帳單問題,請直接<Link>聯絡我們</Link>。",
"account_upgrade_dialog_billing_contact_website": "如有帳單問題,請參考我們的<Link>網站</Link>。",
"account_upgrade_dialog_button_cancel_subscription": "取消訂閱",
"account_upgrade_dialog_button_cancel": "取消",
"account_upgrade_dialog_button_pay_now": "立即付款並訂閱",
"account_upgrade_dialog_button_redirect_signup": "立即註冊",
"account_upgrade_dialog_button_update_subscription": "更新訂閱",
"account_upgrade_dialog_cancel_warning": "這將<strong>取消你的訂閱</strong>,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及緩存在伺服器上的訊息<strong>將被刪除</strong>。",
"account_upgrade_dialog_cancel_warning": "這將<strong>取消你的訂閱</strong>,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及快取在伺服器上的訊息<strong>將被刪除</strong>。",
"account_upgrade_dialog_interval_monthly": "每月",
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "節省高達 {{discount}}%",
"account_upgrade_dialog_interval_yearly_discount_save": "節省 {{discount}}%",
"account_upgrade_dialog_interval_yearly": "每年",
"account_upgrade_dialog_proration_info": "<strong>按比例分配</strong>:在付費計劃之間升級時,差價將被<strong>立刻收取</strong>。在降級到較低級別時,餘額將被用於支付未來的賬單周期。",
"account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 1 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
"account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,<strong>請至少刪除 {{count}} 項保留</strong>。你可以在<Link>設置</Link>中刪除保留。",
"account_upgrade_dialog_tier_current_label": "當前",
"account_upgrade_dialog_tier_features_attachment_file_size": "每個文件 {{filesize}}",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總存儲空間",
"account_upgrade_dialog_tier_features_calls_one": "每日一通電話",
"account_upgrade_dialog_tier_features_calls_other": "每日{{calls}} 通電話",
"account_upgrade_dialog_tier_features_emails_one": "每日一封郵件",
"account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 條郵件",
"account_upgrade_dialog_tier_features_messages_one": "每日一條訊息",
"account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 條訊息",
"account_upgrade_dialog_proration_info": "<strong>按比例計費</strong>:升級到其他付費方案時,系統會<strong>立即收取</strong>差價。降級到較低等級時,餘額會用於支付未來的帳單週期。",
"account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,<strong>請至少刪除 1 項保留</strong>。你可以在<Link>設定</Link>中移除保留。",
"account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,<strong>請至少刪除 {{count}} 項保留</strong>。你可以在<Link>設定</Link>中移除保留。",
"account_upgrade_dialog_tier_current_label": "目前",
"account_upgrade_dialog_tier_features_attachment_file_size": "每個檔案 {{filesize}}",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總儲存空間",
"account_upgrade_dialog_tier_features_calls_one": "每日 {{calls}} 通電話",
"account_upgrade_dialog_tier_features_calls_other": "每日 {{calls}} 通電話",
"account_upgrade_dialog_tier_features_emails_one": "每日 {{emails}} 封電子郵件",
"account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 封電子郵件",
"account_upgrade_dialog_tier_features_messages_one": "每日 {{messages}} 則訊息",
"account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 則訊息",
"account_upgrade_dialog_tier_features_no_calls": "沒有電話",
"account_upgrade_dialog_tier_features_no_reservations": "無保留主題",
"account_upgrade_dialog_tier_features_reservations_one": "保留一條主題",
"account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 條主題",
"account_upgrade_dialog_tier_features_reservations_one": "保留 {{reservations}} 個主題",
"account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 個主題",
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月計費。",
"account_upgrade_dialog_tier_price_billed_yearly": "{{價格}} 按年計費。節省 {{save}}。",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} 按年計費。節省 {{save}}。",
"account_upgrade_dialog_tier_price_per_month": "月",
"account_upgrade_dialog_tier_selected_label": "已選",
"account_upgrade_dialog_title": "更改帳戶等級",
"account_usage_attachment_storage_description": "每個文件 {{filesize}},在 {{expiry}} 後刪除",
"account_usage_attachment_storage_title": "附件存儲",
"account_usage_basis_ip_description": "此帳戶的使用統計資訊和限制基於你的 IP 地址,因此可能會與其他用戶共享。上面顯示的限制是基於現有速率限制的近似值。",
"account_upgrade_dialog_title": "變更帳戶等級",
"account_usage_attachment_storage_description": "每個檔案 {{filesize}},在 {{expiry}} 後刪除",
"account_usage_attachment_storage_title": "附件儲存",
"account_usage_basis_ip_description": "此帳戶會依你的 IP 位址計算使用統計與限制,因此可能會與其他使用者共用。上方限制是根據現有速率限制估算的近似值。",
"account_usage_calls_none": "此帳號無法撥打電話",
"account_usage_calls_title": "已撥打電話",
"account_usage_cannot_create_portal_session": "無法打開計費門戶",
"account_usage_emails_title": "已發送電子郵件",
"account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置",
"account_usage_cannot_create_portal_session": "無法開啟計費入口網站",
"account_usage_emails_title": "已傳送電子郵件",
"account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重設",
"account_usage_messages_title": "已發布訊息",
"account_usage_of_limit": "{{limit}} 的",
"account_usage_of_limit": "/ {{limit}}",
"account_usage_reservations_none": "此帳戶沒有保留主題",
"account_usage_reservations_title": "保留主題",
"account_usage_title": "使用量",
"account_usage_unlimited": "無限",
"action_bar_account": "帳戶",
"action_bar_change_display_name": "更改顯示名稱",
"action_bar_change_display_name": "變更顯示名稱",
"action_bar_clear_notifications": "清除所有通知",
"action_bar_logo_alt": "ntfy 標識",
"action_bar_logo_alt": "ntfy 標誌",
"action_bar_mute_notifications": "靜音",
"action_bar_profile_logout": "登出",
"action_bar_profile_settings": "設定",
"action_bar_profile_title": "個人資料",
"action_bar_reservation_add": "保留主題",
"action_bar_reservation_delete": "移除保留",
"action_bar_reservation_edit": "更改保留",
"action_bar_reservation_edit": "變更保留",
"action_bar_reservation_limit_reached": "達到限制",
"action_bar_send_test_notification": "發送測試通知",
"action_bar_send_test_notification": "傳送測試通知",
"action_bar_settings": "設定",
"action_bar_show_menu": "顯示選單",
"action_bar_sign_in": "登錄",
"action_bar_sign_in": "登入",
"action_bar_sign_up": "註冊",
"action_bar_toggle_action_menu": "開啟或關閉操作選單",
"action_bar_toggle_mute": "通知靜音/解除通知靜音",
"action_bar_unmute_notifications": "取消靜音",
"action_bar_unsubscribe": "取消訂閱",
"alert_notification_ios_install_required_description": "要接收通知,請在 iOS 上點擊共享,然後添加到主屏幕",
"alert_notification_ios_install_required_description": "若要接收通知,請在 iOS 上點選 [分享],然後選擇 [加入主畫面]",
"alert_notification_ios_install_required_title": "需要安裝 iOS 應用程式",
"alert_notification_permission_denied_description": "你已禁用通知。要重新啟用通知,請在瀏覽器設置中啟用通知",
"alert_notification_permission_denied_title": "已禁用通知",
"alert_notification_permission_denied_description": "你已停用通知。若要重新啟用通知,請在瀏覽器設定中開啟通知",
"alert_notification_permission_denied_title": "已停用通知",
"alert_notification_permission_required_button": "現在授予",
"alert_notification_permission_required_description": "授予瀏覽器顯示桌面通知的權限",
"alert_notification_permission_required_title": "已禁用通知",
"alert_notification_permission_required_description": "請授予瀏覽器顯示桌面通知的權限",
"alert_notification_permission_required_title": "已停用通知",
"alert_not_supported_context_description": "通知僅支援 HTTPS。這是 <mdnLink>Notifications API</mdnLink> 的限制。",
"alert_not_supported_description": "你的瀏覽器不支援通知",
"alert_not_supported_title": "不支援通知",
"common_add": "新增",
"common_back": "返回",
"common_back": "上一頁",
"common_cancel": "取消",
"common_copy_to_clipboard": "複製到剪貼板",
"common_save": "保存",
"display_name_dialog_description": "為訂閱列表中顯示的主題設置一個替代名稱。這有助於更輕鬆地識別名稱複雜的主題。",
"common_copy_to_clipboard": "複製到剪貼簿",
"common_save": "儲存",
"display_name_dialog_description": "為訂閱清單中顯示的主題設定替代名稱。這有助於更輕鬆地識別名稱複雜的主題。",
"display_name_dialog_placeholder": "顯示名稱",
"display_name_dialog_title": "更改顯示名稱",
"emoji_picker_search_clear": "清除搜索",
"emoji_picker_search_placeholder": "查找表情符號",
"error_boundary_button_copy_stack_trace": "複製堆疊追踪",
"error_boundary_button_reload_ntfy": "重新加載 ntfy",
"error_boundary_description": "這顯然不應該發生。對此非常抱歉。<br/>如果你有時間,請<githubLink>在GitHub</githubLink>上報告,或通過<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>告訴我們。",
"display_name_dialog_title": "變更顯示名稱",
"emoji_picker_search_clear": "清除搜尋",
"emoji_picker_search_placeholder": "搜尋表情符號",
"error_boundary_button_copy_stack_trace": "複製堆疊追蹤",
"error_boundary_button_reload_ntfy": "重新載入 ntfy",
"error_boundary_description": "這顯然不應該發生。對此非常抱歉。<br/>如果你有時間,請在<githubLink>GitHub</githubLink>回報,或透過<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>聯絡我們。",
"error_boundary_gathering_info": "收集更多資訊……",
"error_boundary_stack_trace": "堆疊追踪",
"error_boundary_stack_trace": "堆疊追蹤",
"error_boundary_title": "天啊,ntfy 崩潰了",
"error_boundary_unsupported_indexeddb_description": "Ntfy Web應用程式需要IndexedDB才能運行,且你的瀏覽器在隱私瀏覽模式下不支援IndexedDB。<br/><br/>儘管這很不幸,但在隱私瀏覽模式下使用ntfy Web應用程式也沒有多大意義,因為所有東西都存儲在瀏覽器存儲中。你可以在<githubLink>本GitHub問題</githubLink>中閱讀有關它的更多資訊,或者在<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>上與我們交談。",
"error_boundary_unsupported_indexeddb_description": "ntfy Web 應用程式需要 IndexedDB 才能運作,而你的瀏覽器在隱私瀏覽模式下不支援 IndexedDB。<br/><br/>很遺憾,不過在隱私瀏覽模式下使用 ntfy Web 應用程式其實也不太合理,因為所有資料都儲存在瀏覽器儲存空間中。你可以在<githubLink>這個 GitHub issue</githubLink>中閱讀更多資訊,或透過<discordLink>Discord</discordLink>或<matrixLink>Matrix</matrixLink>與我們討論。",
"error_boundary_unsupported_indexeddb_title": "不支援隱私瀏覽",
"login_disabled": "登錄已禁用",
"login_form_button_submit": "登錄",
"login_disabled": "登入已停用",
"login_form_button_submit": "登入",
"login_link_signup": "註冊",
"login_title": "請登錄你的 ntfy 帳戶",
"message_bar_error_publishing": "發佈通知時出錯",
"login_title": "請登入你的 ntfy 帳戶",
"message_bar_error_publishing": "發布通知時發生錯誤",
"message_bar_publish": "發布訊息",
"message_bar_show_dialog": "顯示發布對話框",
"message_bar_show_dialog": "顯示發布對話方塊",
"message_bar_type_message": "在此處輸入訊息",
"nav_button_account": "帳戶",
"nav_button_all_notifications": "全部通知",
"nav_button_connecting": "正在連接",
"nav_button_documentation": "文檔",
"nav_button_connecting": "正在連線",
"nav_button_documentation": "說明",
"nav_button_muted": "已暫停通知",
"nav_button_publish_message": "發布通知",
"nav_button_settings": "設定",
@@ -191,44 +191,44 @@
"nav_upgrade_banner_description": "保留主題,更多訊息和郵件,以及更大的附件",
"nav_upgrade_banner_label": "升級到 ntfy Pro",
"notifications_actions_failed_notification": "通知失敗",
"notifications_actions_http_request_title": "發送 HTTP {{method}} 到 {{url}}",
"notifications_actions_not_supported": "網頁應用程序不支援此操作",
"notifications_actions_http_request_title": "傳送 HTTP {{method}} 到 {{url}}",
"notifications_actions_not_supported": "網頁應用程式不支援此操作",
"notifications_actions_open_url_title": "轉到 {{url}}",
"notifications_attachment_copy_url_button": "複製連結地址",
"notifications_attachment_copy_url_title": "將附件中連結地址複製到剪貼板",
"notifications_attachment_file_app": "安卓應用程式",
"notifications_attachment_file_audio": "聲音文件",
"notifications_attachment_file_document": "其他文件",
"notifications_attachment_file_image": "圖片文件",
"notifications_attachment_file_video": "影片文件",
"notifications_attachment_copy_url_button": "複製 URL",
"notifications_attachment_copy_url_title": "將附件 URL 複製到剪貼簿",
"notifications_attachment_file_app": "Android 應用程式檔案",
"notifications_attachment_file_audio": "音訊檔案",
"notifications_attachment_file_document": "其他檔案",
"notifications_attachment_file_image": "圖片檔案",
"notifications_attachment_file_video": "影片檔案",
"notifications_attachment_image": "附件圖片",
"notifications_attachment_link_expired": "下載連結已過期",
"notifications_attachment_link_expires": "連結在 {{date}} 過期",
"notifications_attachment_open_button": "打開附件",
"notifications_attachment_open_button": "開啟附件",
"notifications_attachment_open_title": "轉到 {{url}}",
"notifications_click_copy_url_button": "複製鏈結",
"notifications_click_copy_url_title": "複製鏈結地址到剪貼板",
"notifications_click_open_button": "打開鏈結",
"notifications_copied_to_clipboard": "複製到剪貼板",
"notifications_click_copy_url_button": "複製連結",
"notifications_click_copy_url_title": "將連結 URL 複製到剪貼簿",
"notifications_click_open_button": "開啟連結",
"notifications_copied_to_clipboard": "複製到剪貼簿",
"notifications_delete": "刪除",
"notifications_example": "示例",
"notifications_example": "範例",
"notifications_list_item": "通知",
"notifications_list": "通知列表",
"notifications_loading": "正在加載通知……",
"notifications_list": "通知清單",
"notifications_loading": "正在載入通知……",
"notifications_mark_read": "標記為已讀",
"notifications_more_details": "有關更多資訊,請查看<websiteLink>網站</websiteLink>或<docsLink>文檔</docsLink>。",
"notifications_more_details": "如需更多資訊,請參閱<websiteLink>網站</websiteLink>或<docsLink>說明</docsLink>。",
"notifications_new_indicator": "新通知",
"notifications_none_for_any_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題鏈結即可。以下是使用你的主題的示例。",
"notifications_none_for_any_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。以下是使用你的主題的範例。",
"notifications_none_for_any_title": "你尚未收到任何通知。",
"notifications_none_for_topic_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題連結即可。",
"notifications_none_for_topic_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。",
"notifications_none_for_topic_title": "你尚未收到有關此主題的任何通知。",
"notifications_no_subscriptions_description": "點擊 \"{{linktext}}\" 連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 發送訊息,你將在這裡收到通知。",
"notifications_no_subscriptions_title": "看起來你還未有任何訂閱。",
"notifications_priority_x": "優先級 {{priority}}",
"notifications_no_subscriptions_description": "點選「{{linktext}}」連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 傳送訊息,並在這裡收到通知。",
"notifications_no_subscriptions_title": "看起來你還沒有任何訂閱。",
"notifications_priority_x": "優先順序 {{priority}}",
"notifications_tags": "標記",
"prefs_appearance_language_title": "語言",
"prefs_appearance_theme_dark": "黑暗模式",
"prefs_appearance_theme_light": "光亮模式",
"prefs_appearance_theme_dark": "深色模式",
"prefs_appearance_theme_light": "淺色模式",
"prefs_appearance_theme_system": "系統 (預設)",
"prefs_appearance_theme_title": "主題",
"prefs_appearance_title": "外觀",
@@ -238,171 +238,171 @@
"prefs_notifications_delete_after_one_day": "一天後",
"prefs_notifications_delete_after_one_month_description": "一個月後自動刪除通知",
"prefs_notifications_delete_after_one_month": "一個月後",
"prefs_notifications_delete_after_one_week_description": "一周後自動刪除通知",
"prefs_notifications_delete_after_one_week": "一周後",
"prefs_notifications_delete_after_one_week_description": "一週後自動刪除通知",
"prefs_notifications_delete_after_one_week": "一週後",
"prefs_notifications_delete_after_three_hours_description": "三小時後自動刪除通知",
"prefs_notifications_delete_after_three_hours": "三小時後",
"prefs_notifications_delete_after_title": "刪除通知",
"prefs_notifications_min_priority_any": "任意優先級",
"prefs_notifications_min_priority_default_and_higher": "默認優先級或更高",
"prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先級如何",
"prefs_notifications_min_priority_description_max": "僅顯示最高優先級的通知",
"prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先級為{{number}}({{name}})或以上的通知",
"prefs_notifications_min_priority_high_and_higher": "高優先級或更高",
"prefs_notifications_min_priority_low_and_higher": "低優先級或更高",
"prefs_notifications_min_priority_max_only": "僅最高優先級",
"prefs_notifications_min_priority_title": "最低優先級",
"prefs_notifications_min_priority_any": "任何優先順序",
"prefs_notifications_min_priority_default_and_higher": "預設優先順序或更高",
"prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先順序如何",
"prefs_notifications_min_priority_description_max": "僅顯示最高優先順序的通知",
"prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先順序為 {{number}}({{name}})或以上的通知",
"prefs_notifications_min_priority_high_and_higher": "高優先順序或更高",
"prefs_notifications_min_priority_low_and_higher": "低優先順序或更高",
"prefs_notifications_min_priority_max_only": "僅最高優先順序",
"prefs_notifications_min_priority_title": "最低優先順序",
"prefs_notifications_sound_description_none": "收到通知時不播放任何聲音",
"prefs_notifications_sound_description_some": "收到通知時播放 {{sound}} 聲音",
"prefs_notifications_sound_no_sound": "靜音",
"prefs_notifications_sound_play": "播放選中聲音",
"prefs_notifications_sound_play": "播放選取的聲音",
"prefs_notifications_sound_title": "通知提示音",
"prefs_notifications_title": "通知",
"prefs_notifications_web_push_disabled_description": "當網頁程式在運行時將會收到通知 (透過 WebSocket)",
"prefs_notifications_web_push_disabled": "己暫用",
"prefs_notifications_web_push_enabled_description": "即使網頁程式未有運街亦會收到通知 (via Web Push)",
"prefs_notifications_web_push_enabled": "己為 {{server}} 啟用",
"prefs_notifications_web_push_disabled_description": "網頁應用程式執行時會收到通知(透過 WebSocket)",
"prefs_notifications_web_push_disabled": "已停用",
"prefs_notifications_web_push_enabled_description": "即使網頁應用程式未執行,也會收到通知(透過 Web Push)",
"prefs_notifications_web_push_enabled": "已為 {{server}} 啟用",
"prefs_notifications_web_push_title": "背景通知",
"prefs_reservations_add_button": "新增保留主題",
"prefs_reservations_delete_button": "重置主題訪問",
"prefs_reservations_description": "你可以在此處保留主題名稱供個人使用。保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。",
"prefs_reservations_dialog_access_label": "訪問",
"prefs_reservations_dialog_description": "保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。",
"prefs_reservations_delete_button": "重設主題存取權限",
"prefs_reservations_description": "你可以在此保留主題名稱供個人使用。保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。",
"prefs_reservations_dialog_access_label": "存取權限",
"prefs_reservations_dialog_description": "保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。",
"prefs_reservations_dialog_title_add": "保留主題",
"prefs_reservations_dialog_title_delete": "刪除主題保留",
"prefs_reservations_dialog_title_edit": "編輯保留主題",
"prefs_reservations_dialog_topic_label": "主題",
"prefs_reservations_edit_button": "編輯主題訪問",
"prefs_reservations_edit_button": "編輯主題存取權限",
"prefs_reservations_limit_reached": "你已達到保留主題限制。",
"prefs_reservations_table_access_header": "訪問",
"prefs_reservations_table_click_to_subscribe": "點擊以訂閱",
"prefs_reservations_table_everyone_deny_all": "只有我可以發佈和訂閱",
"prefs_reservations_table_everyone_read_only": "我可以發佈和訂閱,每個人都可以訂閱",
"prefs_reservations_table_everyone_read_write": "每個人都可以發佈和訂閱",
"prefs_reservations_table_everyone_write_only": "我可以發佈和訂閱,每個人都可以發佈",
"prefs_reservations_table_access_header": "存取權限",
"prefs_reservations_table_click_to_subscribe": "點選以訂閱",
"prefs_reservations_table_everyone_deny_all": "只有我可以發布和訂閱",
"prefs_reservations_table_everyone_read_only": "我可以發布和訂閱,每個人都可以訂閱",
"prefs_reservations_table_everyone_read_write": "每個人都可以發布和訂閱",
"prefs_reservations_table_everyone_write_only": "我可以發布和訂閱,每個人都可以發布",
"prefs_reservations_table_not_subscribed": "未訂閱",
"prefs_reservations_table_topic_header": "主題",
"prefs_reservations_table": "保留主題表格",
"prefs_reservations_title": "保留主題",
"prefs_users_add_button": "新增使用者",
"prefs_users_delete_button": "刪除用戶",
"prefs_users_description_no_sync": "用戶和密碼不會同步到你的賬戶。",
"prefs_users_description": "在此處新增/刪除受保護主題的使用者。請注意,使用者名和密碼將存儲在瀏覽器的本地存儲中。",
"prefs_users_dialog_base_url_label": "服務連結地址,例如 https://ntfy.sh",
"prefs_users_delete_button": "刪除使用者",
"prefs_users_description_no_sync": "使用者和密碼不會同步到你的帳戶。",
"prefs_users_description": "在此新增或刪除受保護主題的使用者。請注意,使用者名稱和密碼會儲存在瀏覽器的本機儲存空間中。",
"prefs_users_dialog_base_url_label": "服務 URL,例如 https://ntfy.sh",
"prefs_users_dialog_password_label": "密碼",
"prefs_users_dialog_title_add": "新增使用者",
"prefs_users_dialog_title_edit": "編輯使用者",
"prefs_users_dialog_username_label": "使用者名,例如 phil",
"prefs_users_edit_button": "編輯用戶",
"prefs_users_table_base_url_header": "服務連結地址",
"prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登錄用戶",
"prefs_users_table_user_header": "用戶",
"prefs_users_table": "用戶表",
"prefs_users_dialog_username_label": "使用者名稱,例如 phil",
"prefs_users_edit_button": "編輯使用者",
"prefs_users_table_base_url_header": "服務 URL",
"prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登入使用者",
"prefs_users_table_user_header": "使用者",
"prefs_users_table": "使用者表格",
"prefs_users_title": "管理使用者",
"priority_default": "預設",
"priority_high": "高",
"priority_low": "低",
"priority_max": "最高",
"priority_min": "最低",
"publish_dialog_attached_file_filename_placeholder": "附件文件名",
"publish_dialog_attached_file_remove": "刪除附件文件",
"publish_dialog_attached_file_title": "附件文件:",
"publish_dialog_attach_label": "附件連結地址",
"publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 文件限制和配額,剩餘 {{remainingBytes}}",
"publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 文件限制",
"publish_dialog_attached_file_filename_placeholder": "附件檔名",
"publish_dialog_attached_file_remove": "刪除附件檔案",
"publish_dialog_attached_file_title": "附件檔案:",
"publish_dialog_attach_label": "附件 URL",
"publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 的檔案限制和配額,剩餘 {{remainingBytes}}",
"publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 的檔案限制",
"publish_dialog_attachment_limits_quota_reached": "超過配額,剩餘 {{remainingBytes}}",
"publish_dialog_attach_placeholder": "使用鏈結地址附加文件,例如 https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "移除附件鏈結地址",
"publish_dialog_base_url_label": "服務鏈結地址",
"publish_dialog_base_url_placeholder": "服務鏈結地址,例如 https://example.com",
"publish_dialog_button_cancel_sending": "取消發送",
"publish_dialog_attach_placeholder": "透過 URL 附加檔案,例如 https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "移除附件 URL",
"publish_dialog_base_url_label": "服務 URL",
"publish_dialog_base_url_placeholder": "服務 URL,例如 https://example.com",
"publish_dialog_button_cancel_sending": "取消傳送",
"publish_dialog_button_cancel": "取消",
"publish_dialog_button_send": "發送",
"publish_dialog_button_send": "傳送",
"publish_dialog_call_item": "撥打電話 {{number}}",
"publish_dialog_call_label": "撥號",
"publish_dialog_call_reset": "清空撥號",
"publish_dialog_checkbox_markdown": "格式化為 Markdown",
"publish_dialog_checkbox_publish_another": "發布另一個",
"publish_dialog_chip_attach_file_label": "本地文件附件",
"publish_dialog_chip_attach_url_label": "鏈結附件地址",
"publish_dialog_chip_attach_file_label": "本機檔案附件",
"publish_dialog_chip_attach_url_label": "透過 URL 附加檔案",
"publish_dialog_chip_call_label": "撥號",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "未驗證的電話號碼",
"publish_dialog_chip_click_label": "點擊鏈結地址",
"publish_dialog_chip_click_label": "點選 URL",
"publish_dialog_chip_delay_label": "延期投遞",
"publish_dialog_chip_email_label": "轉發郵件",
"publish_dialog_chip_email_label": "轉寄電子郵件",
"publish_dialog_chip_topic_label": "變更主題",
"publish_dialog_click_label": "點擊鏈結地址",
"publish_dialog_click_placeholder": "點擊通知時打開鏈結地址",
"publish_dialog_click_reset": "移除點擊連結地址",
"publish_dialog_click_label": "點選 URL",
"publish_dialog_click_placeholder": "點選通知時開啟的 URL",
"publish_dialog_click_reset": "移除點選 URL",
"publish_dialog_delay_label": "延期",
"publish_dialog_delay_placeholder": "延期投遞,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(僅限英語)",
"publish_dialog_delay_reset": "刪除延期投遞",
"publish_dialog_details_examples_description": "有關所有發送功能的範例和詳細說明,請參閱<docsLink>文檔</docsLink>。",
"publish_dialog_drop_file_here": "將文件拖拽至此",
"publish_dialog_details_examples_description": "如需所有傳送功能的範例和詳細說明,請參閱<docsLink>說明</docsLink>。",
"publish_dialog_drop_file_here": "將檔案拖曳到這裡",
"publish_dialog_email_label": "電子郵件",
"publish_dialog_email_placeholder": "將通知轉發到的地址,例如 phil@example.com",
"publish_dialog_email_reset": "移除電子郵件轉發",
"publish_dialog_email_placeholder": "要轉寄通知的電子郵件地址,例如 phil@example.com",
"publish_dialog_email_reset": "移除電子郵件轉寄",
"publish_dialog_emoji_picker_show": "選擇表情符號",
"publish_dialog_filename_label": "文件名",
"publish_dialog_filename_placeholder": "附件文件名",
"publish_dialog_filename_label": "檔名",
"publish_dialog_filename_placeholder": "附件檔名",
"publish_dialog_message_label": "訊息",
"publish_dialog_message_placeholder": "在此輸入訊息",
"publish_dialog_message_published": "已發布通知",
"publish_dialog_other_features": "其它功能:",
"publish_dialog_priority_default": "默認優先級",
"publish_dialog_priority_high": "高優先級",
"publish_dialog_priority_label": "優先級",
"publish_dialog_priority_low": "低優先級",
"publish_dialog_priority_max": "最高優先級",
"publish_dialog_priority_min": "最低優先級",
"publish_dialog_other_features": "其他功能:",
"publish_dialog_priority_default": "預設優先順序",
"publish_dialog_priority_high": "高優先順序",
"publish_dialog_priority_label": "優先順序",
"publish_dialog_priority_low": "低優先順序",
"publish_dialog_priority_max": "最高優先順序",
"publish_dialog_priority_min": "最低優先順序",
"publish_dialog_progress_uploading_detail": "正在上傳 {{loaded}}/{{total}} ({{percent}}%) ……",
"publish_dialog_progress_uploading": "正在上傳……",
"publish_dialog_tags_label": "標記",
"publish_dialog_tags_placeholder": "英文逗號分隔標記列表,例如 warning, srv1-backup",
"publish_dialog_tags_placeholder": "以英文逗號分隔的標記清單,例如 warning, srv1-backup",
"publish_dialog_title_label": "主題",
"publish_dialog_title_no_topic": "發布通知",
"publish_dialog_title_placeholder": "主題標題,例如:磁碟空間警告",
"publish_dialog_title_topic": "發布到 {{topic}}",
"publish_dialog_topic_label": "主題名稱",
"publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts",
"publish_dialog_topic_reset": "重置主題",
"reservation_delete_dialog_action_delete_description": "緩存的郵件和附件將被永久刪除。此操作無法撤銷。",
"reservation_delete_dialog_action_delete_title": "刪除緩存的郵件和附件",
"reservation_delete_dialog_action_keep_description": "緩存在伺服器上的訊息和附件將對知道主題名稱的人公開可見。",
"reservation_delete_dialog_action_keep_title": "保留緩存的郵件和附件",
"reservation_delete_dialog_description": "刪除保留會放棄對該主題的所有權,並允許其他人保留它。你可以保留或刪除現有郵件和附件。",
"publish_dialog_topic_reset": "重設主題",
"reservation_delete_dialog_action_delete_description": "系統會永久刪除快取的訊息和附件。此操作無法復原。",
"reservation_delete_dialog_action_delete_title": "刪除快取的訊息和附件",
"reservation_delete_dialog_action_keep_description": "只要知道主題名稱,任何人都能看到伺服器快取的訊息和附件。",
"reservation_delete_dialog_action_keep_title": "保留快取的訊息和附件",
"reservation_delete_dialog_description": "刪除保留會放棄該主題的所有權,並允許其他人保留。你可以保留或刪除現有訊息和附件。",
"reservation_delete_dialog_submit_button": "刪除保留",
"reserve_dialog_checkbox_label": "保留主題並配置訪問",
"signup_already_have_account": "已有帳戶?登錄!",
"signup_disabled": "註冊已禁用",
"signup_error_creation_limit_reached": "已達到帳戶創建限制",
"signup_error_username_taken": "用戶名 {{username}} 已被取用",
"reserve_dialog_checkbox_label": "保留主題並設定存取權限",
"signup_already_have_account": "已有帳戶?請登入!",
"signup_disabled": "註冊已停用",
"signup_error_creation_limit_reached": "已達到帳戶建立限制",
"signup_error_username_taken": "使用者名稱 {{username}} 已被取用",
"signup_form_button_submit": "註冊",
"signup_form_confirm_password": "確認密碼",
"signup_form_password": "密碼",
"signup_form_toggle_password_visibility": "切換密碼可見性",
"signup_form_username": "用戶名",
"signup_title": "創建一個 ntfy 帳戶",
"signup_form_username": "使用者名稱",
"signup_title": "建立 ntfy 帳戶",
"subscribe_dialog_error_topic_already_reserved": "主題已保留",
"subscribe_dialog_error_user_anonymous": "匿名",
"subscribe_dialog_error_user_not_authorized": "未授權 {{username}} 使用者",
"subscribe_dialog_login_button_login": "登入",
"subscribe_dialog_login_description": "本主題受密碼保護,請輸入用戶名和密碼以訂閱。",
"subscribe_dialog_login_description": "此主題受到密碼保護,請輸入使用者名稱和密碼以訂閱。",
"subscribe_dialog_login_password_label": "密碼",
"subscribe_dialog_login_title": "請登錄",
"subscribe_dialog_login_username_label": "用戶名,例如 phil",
"subscribe_dialog_subscribe_base_url_label": "服務地址地址",
"subscribe_dialog_login_title": "請登入",
"subscribe_dialog_login_username_label": "使用者名稱,例如 phil",
"subscribe_dialog_subscribe_base_url_label": "服務 URL",
"subscribe_dialog_subscribe_button_cancel": "取消",
"subscribe_dialog_subscribe_button_generate_topic_name": "生成名稱",
"subscribe_dialog_subscribe_button_generate_topic_name": "產生名稱",
"subscribe_dialog_subscribe_button_subscribe": "訂閱",
"subscribe_dialog_subscribe_description": "主題可能不受密碼保護,因此請選擇一個不容易被猜中的名字。訂閱後,你可以使用 PUT/POST 通知。",
"subscribe_dialog_subscribe_title": "訂閱主題",
"subscribe_dialog_subscribe_topic_placeholder": "主題名,例如 phil_alerts",
"subscribe_dialog_subscribe_use_another_background_info": "當網頁程式未開啟, 將不會收到來自其他伺服器的通知",
"subscribe_dialog_subscribe_topic_placeholder": "主題名稱,例如 phil_alerts",
"subscribe_dialog_subscribe_use_another_background_info": "網頁應用程式未開啟時,不會收到來自其他伺服器的通知",
"subscribe_dialog_subscribe_use_another_label": "使用其他伺服器",
"web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知",
"web_push_subscription_expiring_body": "開啟 ntfy 以繼續接收通知",
"web_push_subscription_expiring_title": "通知會被暫停",
"web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy",
"web_push_unknown_notification_body": "你可能需要開啟網頁來更新 ntfy",
"web_push_unknown_notification_title": "接收到不明通知",
"account_basics_cannot_edit_or_delete_provisioned_user": "已佈建的使用者無法編輯或刪除",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "無法編輯或刪除已佈建的權杖"
+85 -13
View File
@@ -4,6 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db";
import session from "../src/app/Session";
import { ACTION_HTTP, ACTION_VIEW } from "../src/app/actions";
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n";
@@ -11,8 +12,9 @@ import {
EVENT_MESSAGE,
EVENT_MESSAGE_CLEAR,
EVENT_MESSAGE_DELETE,
WEBPUSH_EVENT_MESSAGE,
WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG,
SW_WEBPUSH_EVENT_MESSAGE,
SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
} from "../src/app/events";
/**
@@ -35,6 +37,7 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast");
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
console.log("[ServiceWorker] Message received", data);
@@ -43,9 +46,24 @@ const handlePushMessage = async (data) => {
const subscription = await db.subscriptions.get(subscriptionId);
if (!subscription) {
console.log("[ServiceWorker] Subscription not found", subscriptionId);
handlePushUnknown(data);
return;
}
// NOTE: As soon as possible, to avoid this Safari error:
// > Push event handling completed without showing any notification via
// > ServiceWorkerRegistration.showNotification(). This may trigger removal of
// > the push subscription.
await self.registration.showNotification(
...toNotificationParams({
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
})
);
// Delete existing notification with same sequence ID (if any)
const sequenceId = message.sequence_id || message.id;
if (sequenceId) {
@@ -71,17 +89,71 @@ const handlePushMessage = async (data) => {
// Broadcast the message to potentially play a sound
broadcastChannel.postMessage(message);
await self.registration.showNotification(
...toNotificationParams({
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
})
);
await maybeExtendToken();
};
const refreshTokenThreshold = 1000 * 60 * 60; // 1 hour
const maybeExtendToken = async () => {
if (import.meta.env.DEV) {
console.warn("[ServiceWorker] Skipping token extension in development since no config.base_url exists");
return;
}
const token = await session.tokenAsync();
if (!token) {
console.debug("[ServiceWorker] No session token, skipping token extension");
return;
}
const lastExtendedAt = await session.lastExtendedAtAsync();
const now = Date.now();
if (lastExtendedAt && now - lastExtendedAt < refreshTokenThreshold) {
console.debug(`[ServiceWorker] Token extended ${Math.floor((now - lastExtendedAt) / 1000 / 60)} minutes ago, skipping`);
return;
}
console.log("[ServiceWorker] Extending user access token");
// duplicated from utils.js#accountTokenUrl since we can't import that here
// as long as there's mp3 and other incompatible imports there
const tokenUrl = `${config.base_url}/v1/account/token`;
try {
const response = await fetch(tokenUrl, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
await session.setLastExtendedAtAsync();
console.log(`[ServiceWorker] Token extended successfully`);
} else {
console.error(`[ServiceWorker] Failed to extend token: ${response.status} ${response.statusText}`);
}
} catch (e) {
console.error("[ServiceWorker] Failed to extend token", e);
}
};
/**
* Registers a periodic-sync listener for `extend_token` (see hooks.js).
* This extends the token regardless of whether the browser is open.
*
* CAVEATS:
* - Chromium-only
* - Only when the PWA is _installed_ (not just running in a browser tab)
* - Only when notifications are granted
*/
self.addEventListener("periodicsync", (event) => {
if (event.tag === SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG) {
console.log(`[ServiceWorker] Received periodicsync event "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`);
event.waitUntil(maybeExtendToken());
}
});
/**
* Handle a message_delete event: delete the notification from the database.
*/
@@ -192,7 +264,7 @@ const handlePush = async (data) => {
// - Web app: hooks.js:handleNotification()
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (data.event === WEBPUSH_EVENT_MESSAGE) {
if (data.event === SW_WEBPUSH_EVENT_MESSAGE) {
const { message } = data;
if (message.event === EVENT_MESSAGE) {
return await handlePushMessage(data);
@@ -201,7 +273,7 @@ const handlePush = async (data) => {
} else if (message.event === EVENT_MESSAGE_CLEAR) {
return await handlePushMessageClear(data);
}
} else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
} else if (data.event === SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
return await handlePushSubscriptionExpiring(data);
}
+69 -9
View File
@@ -4,6 +4,10 @@ import {
accountBillingSubscriptionUrl,
accountEmailUrl,
accountEmailVerifyUrl,
accountEmailPrimaryUrl,
accountEmailResendUrl,
accountPasswordResetRequestUrl,
accountPasswordResetUrl,
accountPasswordUrl,
accountPhoneUrl,
accountPhoneVerifyUrl,
@@ -65,11 +69,12 @@ class AccountApi {
});
}
async create(username, password) {
async create(username, password, email) {
const url = accountUrl(config.base_url);
const body = JSON.stringify({
username,
password,
email: email || "",
});
console.log(`[AccountApi] Creating user account ${url}`);
await fetchOrThrow(url, {
@@ -155,6 +160,7 @@ class AccountApi {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
});
await session.setLastExtendedAtAsync();
}
async deleteToken(token) {
@@ -341,9 +347,11 @@ class AccountApi {
});
}
async verifyEmail(email) {
const url = accountEmailVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending email verification ${url}`);
// startEmailVerification begins adding an email: the server stores a pending verification and
// emails a magic link. The address is not verified until the link is clicked.
async startEmailVerification(email) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Starting email verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
@@ -353,15 +361,67 @@ class AccountApi {
});
}
async addEmail(email, code) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Adding email with verification code ${url}`);
// verifyEmailToken performs verification from the magic-link landing page. It is unauthenticated:
// the token identifies the account, so this works even when clicked from a logged-out browser.
async verifyEmailToken(token) {
const url = accountEmailVerifyUrl(config.base_url);
console.log(`[AccountApi] Verifying email token ${url}`);
await fetchOrThrow(url, {
method: "PUT",
method: "POST",
body: JSON.stringify({
token,
}),
});
}
// resendEmailVerification re-sends the magic link for a pending (unverified) address.
async resendEmailVerification(email) {
const url = accountEmailResendUrl(config.base_url);
console.log(`[AccountApi] Resending email verification ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
code,
}),
});
}
// setPrimaryEmail marks an already-verified address as the primary (recovery) email.
async setPrimaryEmail(email) {
const url = accountEmailPrimaryUrl(config.base_url);
console.log(`[AccountApi] Setting primary email ${url}`);
await fetchOrThrow(url, {
method: "POST",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
}),
});
}
// requestPasswordReset starts the (unauthenticated) reset flow. The identifier is a username or
// primary email. The server always responds uniformly, regardless of whether an account matched.
async requestPasswordReset(identifier) {
const url = accountPasswordResetRequestUrl(config.base_url);
console.log(`[AccountApi] Requesting password reset ${url}`);
await fetchOrThrow(url, {
method: "POST",
body: JSON.stringify({
identifier,
}),
});
}
// resetPassword performs the (unauthenticated) reset from the set-new-password landing page.
async resetPassword(token, password) {
const url = accountPasswordResetUrl(config.base_url);
console.log(`[AccountApi] Resetting password ${url}`);
await fetchOrThrow(url, {
method: "POST",
body: JSON.stringify({
token,
password,
}),
});
}
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import session from "./Session";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import accountApi from "./AccountApi";
// AccountApi.js leans on several singletons; mock them so we can drive token() and assert the
// side effects of sync(), and so importing it doesn't drag in the component/Dexie/i18n trees.
// (vi.mock is hoisted above the imports by vitest, so those imports receive the mocks.)
vi.mock("./Session", () => ({
default: { token: vi.fn(() => "test-token"), setLastExtendedAtAsync: vi.fn(), resetAndRedirect: vi.fn() },
}));
vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } }));
vi.mock("./Prefs", () => ({
default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() },
THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" },
}));
vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } }));
vi.mock("../components/routes", () => ({ default: { login: "/login" } }));
let fetchMock;
// fetchOrThrow treats anything other than HTTP 200 as an error, so a fake response just needs a
// status and a json() method.
const ok = (body = {}) => ({ status: 200, json: async () => body });
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
session.token.mockReturnValue("test-token");
accountApi.tiers = null; // reset the billing-tiers cache between tests
accountApi.listener = null;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("AccountApi.login", () => {
it("POSTs basic auth to the token URL and returns the token", async () => {
fetchMock.mockResolvedValue(ok({ token: "tk_returned" }));
const token = await accountApi.login({ username: "phil", password: "secret" });
expect(token).toBe("tk_returned");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/token");
expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
});
it("throws when the server response has no token", async () => {
fetchMock.mockResolvedValue(ok({}));
await expect(accountApi.login({ username: "phil", password: "secret" })).rejects.toThrow("Cannot find token");
});
});
describe("AccountApi.create", () => {
it("POSTs username/password and defaults email to an empty string", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.create("phil", "pw");
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/account",
expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) })
);
});
});
describe("AccountApi.get", () => {
it("returns the parsed account and notifies the listener", async () => {
fetchMock.mockResolvedValue(ok({ username: "phil" }));
const listener = vi.fn();
accountApi.registerListener(listener);
const account = await accountApi.get();
expect(account).toEqual({ username: "phil" });
expect(listener).toHaveBeenCalledWith({ username: "phil" });
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account");
expect(options.headers.Authorization).toBe("Bearer test-token");
});
});
describe("AccountApi.changePassword", () => {
it("POSTs the current and new passwords", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.changePassword("old", "new");
const [, options] = fetchMock.mock.calls[0];
expect(options.body).toBe(JSON.stringify({ password: "old", new_password: "new" }));
});
});
describe("AccountApi.createToken", () => {
it("converts a positive expiry into an absolute unix timestamp", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); // 1767225600 seconds
fetchMock.mockResolvedValue(ok());
await accountApi.createToken("my label", 3600);
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ label: "my label", expires: 1767225600 + 3600 });
});
it("sends expires=0 for a non-expiring token", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.createToken("forever", 0);
const [, options] = fetchMock.mock.calls[0];
expect(JSON.parse(options.body).expires).toBe(0);
});
});
describe("AccountApi.deleteToken", () => {
it("passes the target token in the X-Token header alongside the bearer token", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.deleteToken("tk_target");
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe("DELETE");
expect(options.headers["X-Token"]).toBe("tk_target");
expect(options.headers.Authorization).toBe("Bearer test-token");
});
});
describe("AccountApi subscriptions", () => {
it("addSubscription POSTs base_url/topic and returns the parsed subscription", async () => {
fetchMock.mockResolvedValue(ok({ id: "sub_1" }));
const subscription = await accountApi.addSubscription("https://ntfy.sh", "mytopic");
expect(subscription).toEqual({ id: "sub_1" });
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/subscription");
expect(options.method).toBe("POST");
expect(JSON.parse(options.body)).toEqual({ base_url: "https://ntfy.sh", topic: "mytopic" });
});
it("deleteSubscription passes baseUrl/topic via headers", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.deleteSubscription("https://ntfy.sh", "mytopic");
const [, options] = fetchMock.mock.calls[0];
expect(options.method).toBe("DELETE");
expect(options.headers["X-BaseURL"]).toBe("https://ntfy.sh");
expect(options.headers["X-Topic"]).toBe("mytopic");
});
});
describe("AccountApi.billingTiers", () => {
it("caches the tiers and only fetches once", async () => {
fetchMock.mockResolvedValue(ok([{ code: "pro" }]));
const first = await accountApi.billingTiers();
const second = await accountApi.billingTiers();
expect(first).toEqual([{ code: "pro" }]);
expect(second).toBe(first);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("AccountApi.requestPasswordReset", () => {
it("POSTs the identifier without any auth header", async () => {
fetchMock.mockResolvedValue(ok());
await accountApi.requestPasswordReset("phil");
const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/password/reset/request");
expect(options.body).toBe(JSON.stringify({ identifier: "phil" }));
expect(options.headers).toBeUndefined();
});
});
describe("AccountApi.sync", () => {
it("returns null and makes no request when there is no token", async () => {
session.token.mockReturnValue(null);
expect(await accountApi.sync()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("resets the session and redirects to login on an unauthorized response", async () => {
fetchMock.mockResolvedValue({ status: 401, json: async () => ({}) });
const result = await accountApi.sync();
expect(result).toBeUndefined();
expect(session.resetAndRedirect).toHaveBeenCalledWith("/login");
});
it("applies language, notification prefs and subscriptions from the fetched account", async () => {
fetchMock.mockResolvedValue(
ok({
language: "de",
notification: { sound: "ding", delete_after: 3600, min_priority: 3 },
subscriptions: [{ topic: "t" }],
reservations: [{ topic: "t" }],
})
);
await accountApi.sync();
expect(i18n.changeLanguage).toHaveBeenCalledWith("de");
expect(prefs.setSound).toHaveBeenCalledWith("ding");
expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600);
expect(prefs.setMinPriority).toHaveBeenCalledWith(3);
expect(subscriptionManager.syncFromRemote).toHaveBeenCalledWith([{ topic: "t" }], [{ topic: "t" }]);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userManager from "./UserManager";
import api from "./Api";
// Api.js talks to the server through the global fetch and looks up credentials via userManager.
// (vi.mock is hoisted above the imports by vitest, so the import above receives the mock.)
vi.mock("./UserManager", () => ({ default: { get: vi.fn() } }));
let fetchMock;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "log").mockImplementation(() => {});
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
userManager.get.mockResolvedValue(undefined); // anonymous by default
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("Api.poll", () => {
it("parses newline-delimited JSON and skips lines without an id", async () => {
const body = [
JSON.stringify({ id: "1", message: "a" }),
JSON.stringify({ event: "keepalive" }), // no id -> skipped
JSON.stringify({ id: "2", message: "b" }),
].join("\n");
fetchMock.mockResolvedValue(new Response(body));
const messages = await api.poll("https://ntfy.sh", "mytopic");
expect(messages.map((m) => m.id)).toEqual(["1", "2"]);
});
it("uses the plain poll URL without a since cursor", async () => {
fetchMock.mockResolvedValue(new Response(""));
await api.poll("https://ntfy.sh", "mytopic");
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1", expect.anything());
});
it("uses the since URL when a cursor is given", async () => {
fetchMock.mockResolvedValue(new Response(""));
await api.poll("https://ntfy.sh", "mytopic", 12345);
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1&since=12345", expect.anything());
});
});
describe("Api.publish", () => {
it("PUTs the message body to the base URL", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hello", { priority: 5, tags: ["warning"] });
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh",
expect.objectContaining({
method: "PUT",
body: JSON.stringify({ topic: "mytopic", message: "Hello", priority: 5, tags: ["warning"] }),
})
);
});
it("attaches basic auth when the user has a password", async () => {
userManager.get.mockResolvedValue({ username: "phil", password: "secret" });
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hi");
const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`);
});
it("attaches bearer auth when the user has a token", async () => {
userManager.get.mockResolvedValue({ token: "tk_abc" });
fetchMock.mockResolvedValue({ status: 200 });
await api.publish("https://ntfy.sh", "mytopic", "Hi");
const [, options] = fetchMock.mock.calls[0];
expect(options.headers.Authorization).toBe("Bearer tk_abc");
});
});
describe("Api.topicAuth", () => {
it("returns true for a 2xx response", async () => {
fetchMock.mockResolvedValue({ status: 200 });
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(true);
});
it("returns false for 401/403", async () => {
fetchMock.mockResolvedValue({ status: 403 });
expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(false);
});
it("throws for any other status", async () => {
fetchMock.mockResolvedValue({ status: 500 });
await expect(api.topicAuth("https://ntfy.sh", "mytopic")).rejects.toThrow("Unexpected server response 500");
});
});
describe("Api web push", () => {
const pushSubscription = {
endpoint: "https://push.example/abc",
keys: { auth: "AUTH", p256dh: "P256" },
};
it("updateWebPush POSTs endpoint, keys and topics to the web push URL", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.updateWebPush(pushSubscription, ["topicA", "topicB"]);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/webpush",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ endpoint: "https://push.example/abc", auth: "AUTH", p256dh: "P256", topics: ["topicA", "topicB"] }),
})
);
});
it("deleteWebPush DELETEs just the endpoint", async () => {
fetchMock.mockResolvedValue({ status: 200 });
await api.deleteWebPush(pushSubscription);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/v1/webpush",
expect.objectContaining({
method: "DELETE",
body: JSON.stringify({ endpoint: "https://push.example/abc" }),
})
);
});
});
+2 -2
View File
@@ -11,12 +11,12 @@ class Notifier {
lastSoundPlayedAt = 0;
async notify(subscription, notification) {
await this.playSound();
if (!this.supported()) {
return;
}
await this.playSound();
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const defaultTitle = topicDisplayName(subscription);
+14 -5
View File
@@ -6,6 +6,15 @@ export const THEME = {
SYSTEM: "system",
};
// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
export const PREF_DEFAULTS = {
sound: "ding",
minPriority: 1,
deleteAfter: 604800, // one week
theme: THEME.SYSTEM,
webPushEnabled: false,
};
class Prefs {
constructor(dbImpl) {
this.db = dbImpl;
@@ -17,7 +26,7 @@ class Prefs {
async sound() {
const sound = await this.db.prefs.get("sound");
return sound ? sound.value : "ding";
return sound ? sound.value : PREF_DEFAULTS.sound;
}
async setMinPriority(minPriority) {
@@ -26,7 +35,7 @@ class Prefs {
async minPriority() {
const minPriority = await this.db.prefs.get("minPriority");
return minPriority ? Number(minPriority.value) : 1;
return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority;
}
async setDeleteAfter(deleteAfter) {
@@ -35,12 +44,12 @@ class Prefs {
async deleteAfter() {
const deleteAfter = await this.db.prefs.get("deleteAfter");
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter;
}
async webPushEnabled() {
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
return webPushEnabled?.value;
return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled;
}
async setWebPushEnabled(enabled) {
@@ -49,7 +58,7 @@ class Prefs {
async theme() {
const theme = await this.db.prefs.get("theme");
return theme?.value ?? THEME.SYSTEM;
return theme?.value ?? PREF_DEFAULTS.theme;
}
async setTheme(mode) {
+20 -1
View File
@@ -1,4 +1,5 @@
import Dexie from "dexie";
import { fadeOut } from "./transition";
/**
* Manages the logged-in user's session and access token.
@@ -36,12 +37,18 @@ class Session {
await this.db.kv.bulkPut([
{ key: "user", value: username },
{ key: "token", value: token },
{ key: "lastExtendedAt", value: Date.now() },
]);
localStorage.setItem("user", username);
localStorage.setItem("token", token);
}
async resetAndRedirect(url) {
async resetAndRedirect(url, { fade = false } = {}) {
// User-initiated exits (logout, account deletion) fade out first, while data's intact, so the
// wipe + reload doesn't flash a broken UI. Error redirects pass no options and cut straight through.
if (fade) {
await fadeOut();
}
await this.db.delete();
localStorage.removeItem("user");
localStorage.removeItem("token");
@@ -52,6 +59,18 @@ class Session {
return (await this.db.kv.get({ key: "user" }))?.value;
}
async tokenAsync() {
return (await this.db.kv.get({ key: "token" }))?.value;
}
async lastExtendedAtAsync() {
return (await this.db.kv.get({ key: "lastExtendedAt" }))?.value;
}
async setLastExtendedAtAsync() {
await this.db.kv.put({ key: "lastExtendedAt", value: Date.now() });
}
exists() {
return this.username() && this.token();
}
+20 -5
View File
@@ -6,7 +6,7 @@ import { topicUrl } from "./utils";
import { messageWithSequenceId } from "./notificationUtils";
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager {
export class SubscriptionManager {
constructor(dbImpl) {
this.db = dbImpl;
}
@@ -65,23 +65,36 @@ class SubscriptionManager {
}
/**
* Upsert a subscription: create it if it doesn't exist yet, or merge the given fields into the
* existing one. Merging matters for account sync, which passes the remote display name and
* reservation -- without it, reserving/unreserving a topic you're already subscribed to would
* never be reflected locally until the database is recreated (e.g. a fresh login). Local-only
* state such as mutedUntil and last is preserved on merge.
*
* @param {string} baseUrl
* @param {string} topic
* @param {object} opts
* @param {boolean} opts.internal
* @returns
*/
async add(baseUrl, topic, opts = {}) {
async upsert(baseUrl, topic, opts = {}) {
const id = topicUrl(baseUrl, topic);
const existingSubscription = await this.get(id);
if (existingSubscription) {
return existingSubscription;
// Avoid a needless write (and the resulting Dexie live-query churn) when nothing changed.
const changed = Object.keys(opts).some((key) => existingSubscription[key] !== opts[key]);
if (!changed) {
return existingSubscription;
}
const updatedSubscription = { ...existingSubscription, ...opts };
await this.db.subscriptions.put(updatedSubscription);
return updatedSubscription;
}
const subscription = {
...opts,
id: topicUrl(baseUrl, topic),
id,
baseUrl,
topic,
mutedUntil: 0,
@@ -101,7 +114,9 @@ class SubscriptionManager {
remoteSubscriptions.map(async (remote) => {
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
const local = await this.add(remote.base_url, remote.topic, {
// upsert(): for topics that already exist locally this merges in the latest remote
// display name and reservation (see upsert() for why this matters).
const local = await this.upsert(remote.base_url, remote.topic, {
displayName: remote.display_name, // May be undefined
reservation, // May be null!
});
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// SubscriptionManager pulls in a handful of browser/Dexie-heavy singletons at import time. Mock
// them so the module imports cleanly under the node test environment; the tests construct their
// own SubscriptionManager with an in-memory fake db, so the real db singleton is never used.
vi.mock("./Api", () => ({ default: {} }));
vi.mock("./Notifier", () => ({ default: {} }));
vi.mock("./Prefs", () => ({ default: {} }));
vi.mock("./db", () => ({ default: () => ({}) }));
const { SubscriptionManager } = await import("./SubscriptionManager");
// Minimal in-memory stand-in for the Dexie "subscriptions" table, implementing just the surface
// that syncFromRemote() (and the upsert/remove/update helpers it calls) touches.
const fakeDb = () => {
const rows = new Map();
return {
rows,
subscriptions: {
get: async (id) => rows.get(id),
put: async (sub) => {
rows.set(sub.id, sub);
},
update: async (id, changes) => {
const existing = rows.get(id);
if (existing) {
rows.set(id, { ...existing, ...changes });
}
},
delete: async (id) => {
rows.delete(id);
},
toArray: async () => Array.from(rows.values()),
},
};
};
const baseUrl = "https://ntfy.sh";
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {});
});
describe("SubscriptionManager.upsert", () => {
it("merges fields into an existing subscription without clobbering local-only state", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic");
await manager.setMutedUntil("https://ntfy.sh/mytopic", 123);
const reservation = { topic: "mytopic", everyone: "deny-all" };
await manager.upsert(baseUrl, "mytopic", { displayName: "My Topic", reservation });
const stored = db.rows.get("https://ntfy.sh/mytopic");
expect(stored.reservation).toEqual(reservation);
expect(stored.displayName).toBe("My Topic");
expect(stored.mutedUntil).toBe(123); // local-only state preserved
});
it("does not write when an existing subscription would not change", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic", { internal: true });
const putSpy = vi.spyOn(db.subscriptions, "put");
const result = await manager.upsert(baseUrl, "mytopic", { internal: true });
expect(putSpy).not.toHaveBeenCalled();
expect(result.topic).toBe("mytopic");
});
});
describe("SubscriptionManager.syncFromRemote", () => {
it("persists a reservation onto a subscription that already exists locally", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
// Topic was subscribed to before it was reserved, so it already exists locally without a
// reservation -- exactly the state when a user clicks "Reserve topic" in the navbar.
await manager.upsert(baseUrl, "mytopic");
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeFalsy();
const reservation = { topic: "mytopic", everyone: "deny-all" };
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], [reservation]);
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toEqual(reservation);
});
it("clears the reservation when the remote no longer reports one", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic", { reservation: { topic: "mytopic", everyone: "deny-all" } });
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic" }], []);
expect(db.rows.get("https://ntfy.sh/mytopic").reservation).toBeNull();
});
it("updates the display name on an existing subscription", async () => {
const db = fakeDb();
const manager = new SubscriptionManager(db);
await manager.upsert(baseUrl, "mytopic");
await manager.syncFromRemote([{ base_url: baseUrl, topic: "mytopic", display_name: "My Topic" }], []);
expect(db.rows.get("https://ntfy.sh/mytopic").displayName).toBe("My Topic");
});
});
File diff suppressed because one or more lines are too long
+16 -6
View File
@@ -31,11 +31,11 @@ export class TopicReservedError extends Error {
}
}
export class AccountCreateLimitReachedError extends Error {
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation
export class AccountActionLimitReachedError extends Error {
static CODE = 42906; // errHTTPTooManyRequestsLimitAccountActions
constructor() {
super("Account creation limit reached");
super("Account action limit reached");
}
}
@@ -51,7 +51,15 @@ export class EmailVerificationCodeInvalidError extends Error {
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
constructor() {
super("Email verification code invalid or expired");
super("Email verification link invalid or expired");
}
}
export class EmailPrimaryElsewhereError extends Error {
static CODE = 40908; // errHTTPConflictEmailPrimaryElsewhere
constructor() {
super("Email address is the recovery email on another account");
}
}
@@ -67,12 +75,14 @@ export const throwAppError = async (response) => {
throw new UserExistsError();
} else if (error.code === TopicReservedError.CODE) {
throw new TopicReservedError();
} else if (error.code === AccountCreateLimitReachedError.CODE) {
throw new AccountCreateLimitReachedError();
} else if (error.code === AccountActionLimitReachedError.CODE) {
throw new AccountActionLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError();
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
throw new EmailVerificationCodeInvalidError();
} else if (error.code === EmailPrimaryElsewhereError.CODE) {
throw new EmailPrimaryElsewhereError();
} else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`);
}
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
UnauthorizedError,
UserExistsError,
TopicReservedError,
AccountActionLimitReachedError,
IncorrectPasswordError,
EmailVerificationCodeInvalidError,
EmailPrimaryElsewhereError,
throwAppError,
fetchOrThrow,
} from "./errors";
// A minimal stand-in for a fetch Response: just the bits errors.js reads.
const fakeResponse = (status, body) => ({
status,
json: async () => {
if (body === undefined) throw new Error("no body");
return body;
},
});
beforeEach(() => {
// errors.js logs to console.log on every error path; keep test output clean.
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("error classes", () => {
it("carry the server error codes from errors.go", () => {
expect(UserExistsError.CODE).toBe(40901);
expect(TopicReservedError.CODE).toBe(40902);
expect(AccountActionLimitReachedError.CODE).toBe(42906);
expect(IncorrectPasswordError.CODE).toBe(40026);
expect(EmailVerificationCodeInvalidError.CODE).toBe(40051);
expect(EmailPrimaryElsewhereError.CODE).toBe(40908);
});
it("are Error subclasses with human-readable messages", () => {
expect(new UnauthorizedError()).toBeInstanceOf(Error);
expect(new UnauthorizedError().message).toBe("Unauthorized");
expect(new UserExistsError().message).toBe("Username already exists");
});
});
describe("throwAppError", () => {
it("maps 401 and 403 to UnauthorizedError", async () => {
await expect(throwAppError(fakeResponse(401))).rejects.toBeInstanceOf(UnauthorizedError);
await expect(throwAppError(fakeResponse(403))).rejects.toBeInstanceOf(UnauthorizedError);
});
it("maps known ntfy error codes to their specific error classes", async () => {
await expect(throwAppError(fakeResponse(409, { code: UserExistsError.CODE }))).rejects.toBeInstanceOf(UserExistsError);
await expect(throwAppError(fakeResponse(409, { code: TopicReservedError.CODE }))).rejects.toBeInstanceOf(TopicReservedError);
await expect(throwAppError(fakeResponse(429, { code: AccountActionLimitReachedError.CODE }))).rejects.toBeInstanceOf(
AccountActionLimitReachedError
);
});
it("wraps an unknown code that carries an error message in a generic Error", async () => {
await expect(throwAppError(fakeResponse(400, { code: 12345, error: "boom" }))).rejects.toThrow("Error 12345: boom");
});
it("throws a generic 'Unexpected response' error when there is no ntfy error body", async () => {
await expect(throwAppError(fakeResponse(500))).rejects.toThrow("Unexpected response 500");
});
});
describe("fetchOrThrow", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns the response unchanged on HTTP 200", async () => {
const response = { status: 200 };
vi.stubGlobal(
"fetch",
vi.fn(async () => response)
);
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).resolves.toBe(response);
});
it("throws on a non-200 response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => fakeResponse(401))
);
await expect(fetchOrThrow("https://ntfy.sh/mytopic/json")).rejects.toBeInstanceOf(UnauthorizedError);
});
});
+4 -2
View File
@@ -8,8 +8,10 @@ export const EVENT_MESSAGE_DELETE = "message_delete";
export const EVENT_MESSAGE_CLEAR = "message_clear";
export const EVENT_POLL_REQUEST = "poll_request";
export const WEBPUSH_EVENT_MESSAGE = "message";
export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
export const SW_WEBPUSH_EVENT_MESSAGE = "message";
export const SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
export const SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG = "extend_token";
// Check if an event is a notification event (message, delete, or read)
export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;
+1 -1
View File
@@ -49,7 +49,7 @@ export const isImage = (attachment) => {
};
export const icon = "/static/images/ntfy.png";
export const badge = "/static/images/mask-icon.svg";
export const badge = "/static/images/ntfy-mask.svg";
/**
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import emojisMapped from "./emojisMapped";
import { formatTitle, formatMessage, isImage, notificationTag, toNotificationParams, messageWithSequenceId } from "./notificationUtils";
// Pick a real alias/emoji pair so we don't hardcode a specific glyph that could change upstream.
const [emojiAlias, emojiChar] = Object.entries(emojisMapped)[0];
describe("formatTitle", () => {
it("returns the bare title when there are no emoji tags", () => {
expect(formatTitle({ title: "Hello", tags: ["not-an-emoji"] })).toBe("Hello");
expect(formatTitle({ title: "Hello" })).toBe("Hello");
});
it("prepends mapped emoji for emoji tags", () => {
expect(formatTitle({ title: "Hello", tags: [emojiAlias] })).toBe(`${emojiChar} Hello`);
});
});
describe("formatMessage", () => {
it("returns the message untouched when the notification has a title", () => {
expect(formatMessage({ title: "T", message: "Body", tags: [emojiAlias] })).toBe("Body");
});
it("prepends emoji to the message when there is no title", () => {
expect(formatMessage({ message: "Body", tags: [emojiAlias] })).toBe(`${emojiChar} Body`);
});
it("falls back to an empty string when message is missing", () => {
expect(formatMessage({})).toBe("");
});
});
describe("isImage", () => {
it("trusts an explicit image MIME type", () => {
expect(isImage({ type: "image/png", name: "whatever.txt" })).toBe(true);
expect(isImage({ type: "application/pdf", name: "x.png" })).toBe(false);
});
it("falls back to the file extension when there is no type", () => {
expect(isImage({ name: "photo.JPEG" })).toBeTruthy();
expect(isImage({ url: "https://ntfy.sh/file/x.webp" })).toBeTruthy();
expect(isImage({ name: "notes.txt" })).toBeFalsy();
});
it("returns false for a missing attachment", () => {
expect(isImage(undefined)).toBe(false);
});
});
describe("notificationTag", () => {
it("scopes the tag by baseUrl, topic and sequence id", () => {
expect(notificationTag("https://ntfy.sh", "mytopic", 42)).toBe("https://ntfy.sh/mytopic/42");
});
});
describe("toNotificationParams", () => {
const baseMessage = {
id: "msg-1",
time: 1700000000,
title: "Title",
message: "Body",
actions: [
{ action: "view", label: "Open" },
{ action: "http", label: "Send" },
{ action: "broadcast", label: "Cast" },
],
};
it("builds the [title, options] tuple consumed by the Notifications API", () => {
const [title, options] = toNotificationParams({
message: baseMessage,
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(title).toBe("Title");
expect(options.body).toBe("Body");
expect(options.tag).toBe("https://ntfy.sh/mytopic/msg-1");
expect(options.timestamp).toBe(baseMessage.time * 1000);
expect(options.data.subscriptionId).toBe("https://ntfy.sh/mytopic");
expect(options.data.topicRoute).toBe("/mytopic");
});
it("keeps only view/http actions and maps them to {action,title}", () => {
const [, options] = toNotificationParams({
message: baseMessage,
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(options.actions).toEqual([
{ action: "Open", title: "Open" },
{ action: "Send", title: "Send" },
]);
});
it("uses the default title when the message has none", () => {
const [title] = toNotificationParams({
message: { id: "x", time: 1, message: "Body" },
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(title).toBe("fallback");
});
it("prefers sequence_id over id for the notification tag", () => {
const [, options] = toNotificationParams({
message: { ...baseMessage, sequence_id: 99 },
defaultTitle: "fallback",
topicRoute: "/mytopic",
baseUrl: "https://ntfy.sh",
topic: "mytopic",
});
expect(options.tag).toBe("https://ntfy.sh/mytopic/99");
});
});
describe("messageWithSequenceId", () => {
it("derives sequenceId from sequence_id when absent", () => {
expect(messageWithSequenceId({ id: "a", sequence_id: 7 }).sequenceId).toBe(7);
});
it("falls back to id when sequence_id is missing", () => {
expect(messageWithSequenceId({ id: "a" }).sequenceId).toBe("a");
});
it("returns the message unchanged when sequenceId already set", () => {
const m = { id: "a", sequenceId: 1 };
expect(messageWithSequenceId(m)).toBe(m);
});
});
+58
View File
@@ -0,0 +1,58 @@
// Fades out and removes the static splash (see web/index.html), called once the app has mounted and
// its data is ready. Idempotent.
// Minimum time the splash stays up, so it doesn't flash-and-vanish on warm-cache loads.
const MIN_VISIBLE_MS = 1000;
// Hide in two phases: fade the logo out, then fade the background away to reveal the app.
// APP_FADE_MS must match the #splash opacity transition in index.html.
const LOGO_FADE_MS = 300;
const APP_FADE_MS = 100;
let removed = false;
const fadeOutAndRemove = () => {
const splash = document.getElementById("splash");
if (!splash) {
return;
}
// Phase 1: freeze the pulse at its current opacity (else stopping the animation snaps to full),
// then fade the logo to 0.
const img = splash.querySelector("img");
if (img) {
const current = getComputedStyle(img).opacity;
img.style.opacity = current;
img.style.animation = "none";
img.getBoundingClientRect(); // force reflow so the fade starts from `current`
img.style.transition = `opacity ${LOGO_FADE_MS}ms ease-out`;
img.style.opacity = "0";
}
// Phase 2: lift the background to fade the app in, then remove the node.
setTimeout(() => {
splash.classList.add("splash-hidden");
const remove = () => splash.remove();
// Ignore the logo's bubbling transitionend; only the background's own fade should remove it.
const onEnd = (event) => {
if (event.target === splash) {
splash.removeEventListener("transitionend", onEnd);
remove();
}
};
splash.addEventListener("transitionend", onEnd);
setTimeout(remove, APP_FADE_MS + 100); // fallback if transitionend never fires
}, LOGO_FADE_MS);
};
const hideSplash = () => {
if (removed) {
return;
}
removed = true;
// performance.now() ~= how long the splash has been visible; hold until MIN_VISIBLE_MS elapses.
const remaining = Math.max(0, MIN_VISIBLE_MS - performance.now());
setTimeout(fadeOutAndRemove, remaining);
};
export default hideSplash;
+49
View File
@@ -0,0 +1,49 @@
// Fade transitions between the app and the auth pages (login/signup/reset): fade #root out, then
// navigate client-side (fading back in) or full-reload (the splash fades the next page in).
const FADE_MS = 150;
const rootNode = () => document.getElementById("root");
// Fade #root out, resolving when done. Exported so callers can await it before their own
// teardown + reload (e.g. resetAndRedirect wiping IndexedDB).
export const fadeOut = () =>
new Promise((resolve) => {
const node = rootNode();
if (!node) {
resolve();
return;
}
node.style.transition = `opacity ${FADE_MS}ms ease-out`;
node.style.opacity = "0";
setTimeout(resolve, FADE_MS);
});
// Fade #root back in, then strip the inline styles (a lingering `transition` would animate future
// opacity changes). setTimeout, not rAF, so a backgrounded tab can't strand #root at opacity 0.
const fadeInRoot = () => {
const node = rootNode();
if (!node) {
return;
}
node.style.opacity = "1";
setTimeout(() => {
node.style.transition = "";
node.style.opacity = "";
}, FADE_MS);
};
// Fade out, navigate client-side, fade the new page in (app -> login/signup, no reload).
export const fadeNavigate = (navigate, to) => {
fadeOut().then(() => {
navigate(to);
fadeInRoot();
});
};
// Fade out, then full-reload to `url` (login/signup -> app needs a reload for the per-user DB).
// The splash fades it back in.
export const fadeReload = (url) => {
fadeOut().then(() => {
window.location.href = url;
});
};
+5 -1
View File
@@ -35,6 +35,10 @@ export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`;
export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`;
export const accountPasswordResetRequestUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset/request`;
export const accountPasswordResetUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset`;
export const validUrl = (url) => url.match(/^https?:\/\/.+/);
@@ -209,7 +213,7 @@ export const formatPrice = (n) => {
};
export const openUrl = (url) => {
window.open(url, "_blank", "noopener,noreferrer");
window.open(url, "_blank", "noreferrer");
};
export const sounds = {
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { THEME } from "./Prefs";
import {
topicUrl,
topicUrlWs,
topicUrlJsonPollWithSince,
accountUrl,
accountTokenUrl,
shortUrl,
expandUrl,
expandSecureUrl,
validUrl,
validTopic,
disallowedTopic,
encodeBase64,
encodeBase64Url,
bearerAuth,
basicAuth,
withBearerAuth,
maybeWithAuth,
splitNoEmpty,
hashCode,
formatBytes,
formatNumber,
formatPrice,
formatShortDuration,
getKebabCaseLangStr,
darkModeEnabled,
urlB64ToUint8Array,
} from "./utils";
describe("URL builders", () => {
it("build topic URLs", () => {
expect(topicUrl("https://ntfy.sh", "mytopic")).toBe("https://ntfy.sh/mytopic");
expect(topicUrlJsonPollWithSince("https://ntfy.sh", "mytopic", 123)).toBe("https://ntfy.sh/mytopic/json?poll=1&since=123");
});
it("rewrite the scheme for websocket URLs", () => {
expect(topicUrlWs("https://ntfy.sh", "mytopic")).toBe("wss://ntfy.sh/mytopic/ws");
expect(topicUrlWs("http://localhost:8080", "mytopic")).toBe("ws://localhost:8080/mytopic/ws");
});
it("build account URLs", () => {
expect(accountUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account");
expect(accountTokenUrl("https://ntfy.sh")).toBe("https://ntfy.sh/v1/account/token");
});
});
describe("url helpers", () => {
it("strip the scheme with shortUrl", () => {
expect(shortUrl("https://ntfy.sh/mytopic")).toBe("ntfy.sh/mytopic");
});
it("expand a bare host to both schemes", () => {
expect(expandUrl("ntfy.sh")).toEqual(["https://ntfy.sh", "http://ntfy.sh"]);
expect(expandSecureUrl("ntfy.sh")).toBe("https://ntfy.sh");
});
it("validate http(s) URLs", () => {
expect(validUrl("https://ntfy.sh")).toBeTruthy();
expect(validUrl("http://ntfy.sh")).toBeTruthy();
expect(validUrl("ftp://ntfy.sh")).toBeFalsy();
});
});
describe("topic validation", () => {
it("rejects disallowed topics (from config.disallowed_topics)", () => {
expect(disallowedTopic("app")).toBe(true);
expect(disallowedTopic("mytopic")).toBe(false);
expect(validTopic("app")).toBe(false);
});
it("accepts well-formed topic names and rejects malformed ones", () => {
expect(validTopic("valid_Topic-123")).toBeTruthy();
expect(validTopic("bad/slash")).toBeFalsy();
expect(validTopic("with space")).toBeFalsy();
expect(validTopic("")).toBeFalsy();
});
});
describe("base64 + auth headers", () => {
it("encodes base64 and base64url", () => {
expect(encodeBase64("hello")).toBe("aGVsbG8=");
expect(encodeBase64Url("hello")).toBe("aGVsbG8");
});
it("builds bearer and basic auth headers", () => {
expect(bearerAuth("tok")).toBe("Bearer tok");
expect(basicAuth("phil", "secret")).toBe(`Basic ${encodeBase64("phil:secret")}`);
expect(withBearerAuth({ "Content-Type": "application/json" }, "tok")).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer tok",
});
});
it("picks the right scheme in maybeWithAuth", () => {
expect(maybeWithAuth({}, { username: "u", password: "p" }).Authorization).toBe(basicAuth("u", "p"));
expect(maybeWithAuth({}, { token: "tok" }).Authorization).toBe(bearerAuth("tok"));
expect(maybeWithAuth({ a: 1 }, undefined)).toEqual({ a: 1 });
});
});
describe("misc pure helpers", () => {
it("splitNoEmpty trims and drops empty entries", () => {
expect(splitNoEmpty("a, b, ,c ", ",")).toEqual(["a", "b", "c"]);
expect(splitNoEmpty("", ",")).toEqual([]);
});
it("hashCode is deterministic", () => {
expect(hashCode("")).toBe(0);
expect(hashCode("a")).toBe(97);
expect(hashCode("hello")).toBe(hashCode("hello"));
});
it("formatBytes is human readable", () => {
expect(formatBytes(0)).toBe("0 bytes");
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
});
it("formatNumber abbreviates round thousands", () => {
expect(formatNumber(0)).toBe(0);
expect(formatNumber(1000)).toBe("1k");
expect(formatNumber(1500)).toBe((1500).toLocaleString());
});
it("formatPrice renders cents as dollars", () => {
expect(formatPrice(100)).toBe("$1");
expect(formatPrice(150)).toBe("$1.5");
});
it("formatShortDuration picks the largest fitting unit", () => {
expect(formatShortDuration(60000, "en")).toContain("minute");
expect(formatShortDuration(3600000, "en")).toContain("hour");
});
it("getKebabCaseLangStr normalizes language tags", () => {
expect(getKebabCaseLangStr("en_US")).toBe("en-US");
expect(getKebabCaseLangStr(undefined)).toBe("en");
expect(getKebabCaseLangStr("")).toBe("en");
});
it("darkModeEnabled honors the theme preference", () => {
expect(darkModeEnabled(false, THEME.DARK)).toBe(true);
expect(darkModeEnabled(true, THEME.LIGHT)).toBe(false);
expect(darkModeEnabled(true, THEME.SYSTEM)).toBe(true);
expect(darkModeEnabled(false, THEME.SYSTEM)).toBe(false);
});
it("urlB64ToUint8Array decodes web push keys", () => {
expect(Array.from(urlB64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
});
});
+212 -137
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import { useContext, useState } from "react";
import {
Alert,
Box,
CardActions,
CardContent,
Chip,
@@ -31,17 +32,24 @@ import {
DialogContent,
TextField,
IconButton,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
DialogContentText,
useTheme,
} from "@mui/material";
import EditIcon from "@mui/icons-material/Edit";
import { Trans, useTranslation } from "react-i18next";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlineOutlined";
import StarIcon from "@mui/icons-material/Star";
import StarBorderIcon from "@mui/icons-material/StarBorder";
import RefreshIcon from "@mui/icons-material/Refresh";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import CelebrationIcon from "@mui/icons-material/Celebration";
import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material";
import ContentCopy from "@mui/icons-material/ContentCopy";
import Public from "@mui/icons-material/Public";
import AddIcon from "@mui/icons-material/Add";
import routes from "./routes";
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils";
@@ -49,10 +57,10 @@ import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App";
import AccountContext from "./AccountContext";
import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles";
import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { ProChip } from "./SubscriptionPopup";
import session from "../app/Session";
@@ -183,7 +191,7 @@ const ChangePasswordDialog = (props) => {
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
<DialogContent>
<TextField
@@ -263,22 +271,23 @@ const AccountType = () => {
}
};
// The account type is a base label ("Admin", "Basic", "Free", or the tier name) plus an optional
// qualifier chip (admin tier status, or the billing interval).
let accountType;
let qualifierChip;
if (account.role === Role.ADMIN) {
const tierSuffix = account.tier
? t("account_basics_tier_admin_suffix_with_tier", {
tier: account.tier.name,
})
accountType = t("account_basics_tier_admin");
qualifierChip = account.tier
? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name })
: t("account_basics_tier_admin_suffix_no_tier");
accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`;
} else if (!account.tier) {
accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic");
} else {
accountType = account.tier.name;
if (account.billing?.interval === SubscriptionInterval.MONTH) {
accountType += ` (${t("account_basics_tier_interval_monthly")})`;
qualifierChip = t("account_basics_tier_interval_monthly");
} else if (account.billing?.interval === SubscriptionInterval.YEAR) {
accountType += ` (${t("account_basics_tier_interval_yearly")})`;
qualifierChip = t("account_basics_tier_interval_yearly");
}
}
@@ -290,6 +299,8 @@ const AccountType = () => {
>
<div>
{accountType}
{qualifierChip && <Chip size="small" label={qualifierChip} sx={{ ml: 1 }} />}
{account.provisioned && <Chip size="small" label={t("account_basics_tier_provisioned")} sx={{ ml: 1 }} />}
{account.billing?.paid_until && !account.billing?.cancel_at && (
<Tooltip
title={t("account_basics_tier_paid_until", {
@@ -359,9 +370,24 @@ const Emails = () => {
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message
const [menuAnchor, setMenuAnchor] = useState(null); // Chip element the actions menu is anchored to
const [menuEmail, setMenuEmail] = useState(null); // The email the open menu acts on
const labelId = "prefVerifiedEmails";
const openMenu = (ev, email) => {
setMenuAnchor(ev.currentTarget);
setMenuEmail(email);
};
const closeMenu = () => {
setMenuAnchor(null);
setMenuEmail(null);
};
const runMenuAction = (fn) => {
closeMenu();
fn(menuEmail.address);
};
const handleDialogOpen = () => {
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
@@ -373,21 +399,36 @@ const Emails = () => {
const handleCopy = (email) => {
copyToClipboard(email);
setSnackOpen(true);
setSnack(t("account_basics_emails_copied_to_clipboard"));
};
const handleDelete = async (email) => {
// runEmailAction wraps an email account action with the shared error handling used by the
// delete/set-primary/resend handlers (redirect on unauthorized, surface a message otherwise).
// The account view refreshes via the server's sync event, so there's no explicit refetch here.
const runEmailAction = async (fn, errorMessage) => {
try {
await accountApi.deleteEmail(email);
await fn();
} catch (e) {
console.log(`[Account] Error deleting email`, e);
console.log(`[Account] Email action failed`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else if (e instanceof EmailPrimaryElsewhereError) {
setSnack(t("account_basics_emails_primary_elsewhere"));
} else {
setSnack(errorMessage ?? e.message);
}
}
};
if (!config.enable_email_verify) {
const handleDelete = (email) => runEmailAction(() => accountApi.deleteEmail(email));
const handleSetPrimary = (email) => runEmailAction(() => accountApi.setPrimaryEmail(email));
const handleResend = (email) =>
runEmailAction(async () => {
await accountApi.resendEmailVerification(email);
setSnack(t("account_basics_emails_resent"));
});
if (!config.enable_emails) {
return null;
}
@@ -407,35 +448,105 @@ const Emails = () => {
);
}
const emails = account?.emails ?? [];
// Verified addresses, primary always first
const verifiedEmails = emails.filter((e) => !e.pending).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0));
const pendingEmails = emails.filter((e) => e.pending);
const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? "";
// Recovery nudges (skipped for provisioned users -- they can't reset, and the Add-email dialog
// explains the limitation): prompt for a first email when there are none, or for a primary when
// there are emails but none is primary.
const recoveryRelevant = config.enable_reset_password && !account?.provisioned;
const hasNoEmails = verifiedEmails.length === 0 && pendingEmails.length === 0;
const showNoEmailWarning = recoveryRelevant && hasNoEmails;
const showNoPrimaryWarning = recoveryRelevant && !hasNoEmails && primaryEmail === "";
return (
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<Pref labelId={labelId} alignTop title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<div aria-labelledby={labelId}>
{account?.emails?.map((email) => (
<Chip
key={email}
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{email}</span>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(email)}
onDelete={() => handleDelete(email)}
/>
))}
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<AddIcon />
</IconButton>
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.75 }}>
{verifiedEmails.map((email) => (
<Chip
key={email.address}
icon={email.primary ? <StarIcon /> : undefined}
label={
<Tooltip
title={email.primary ? t("account_basics_emails_chip_actions_primary") : t("account_basics_emails_chip_actions_verified")}
>
<span>{email.address}</span>
</Tooltip>
}
variant="outlined"
onClick={(ev) => openMenu(ev, email)}
onDelete={() => handleDelete(email.address)}
sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined}
/>
))}
{pendingEmails.map((email) => (
<Chip
key={email.address}
label={
<Tooltip title={t("account_basics_emails_chip_actions_unverified")}>
<span>
{email.address} <em>({t("account_basics_emails_unverified")})</em>
</span>
</Tooltip>
}
variant="outlined"
onClick={(ev) => openMenu(ev, email)}
onDelete={() => handleDelete(email.address)}
sx={{ opacity: 0.7 }}
/>
))}
{verifiedEmails.length === 0 && pendingEmails.length === 0 && <em>{t("account_basics_emails_no_emails_yet")}</em>}
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_emails_dialog_title")}>
<AddIcon />
</IconButton>
</Box>
{showNoEmailWarning && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("account_basics_emails_no_recovery_warning")}
</Alert>
)}
{showNoPrimaryWarning && (
<Alert severity="warning" sx={{ mt: 1 }}>
{t("account_basics_emails_no_primary_warning")}
</Alert>
)}
</div>
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu}>
<MenuItem onClick={() => runMenuAction(handleCopy)}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>{t("common_copy_to_clipboard")}</ListItemText>
</MenuItem>
{menuEmail && !menuEmail.pending && !menuEmail.primary && (
<MenuItem onClick={() => runMenuAction(handleSetPrimary)}>
<ListItemIcon>
<StarBorderIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("account_basics_emails_set_primary")}</ListItemText>
</MenuItem>
)}
{menuEmail && menuEmail.pending && (
<MenuItem onClick={() => runMenuAction(handleResend)}>
<ListItemIcon>
<RefreshIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("account_basics_emails_resend")}</ListItemText>
</MenuItem>
)}
<MenuItem onClick={() => runMenuAction(handleDelete)}>
<ListItemIcon>
<DeleteOutlineIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("account_basics_emails_delete")}</ListItemText>
</MenuItem>
</Menu>
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_basics_emails_copied_to_clipboard")}
/>
<Snackbar open={snack !== ""} autoHideDuration={3000} onClose={() => setSnack("")} message={snack} />
</Portal>
</Pref>
);
@@ -446,18 +557,20 @@ const AddEmailDialog = (props) => {
const { t } = useTranslation();
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const [sent, setSent] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const verifyEmail = async () => {
// handleSubmit starts verification: the server emails a magic link. The pending address shows
// up in the account list as "(unverified)" once the account refreshes.
const handleSubmit = async () => {
try {
setSending(true);
await accountApi.verifyEmail(email);
setVerificationCodeSent(true);
setError(""); // Clear any error from a previous attempt
await accountApi.startEmailVerification(email);
setSent(true);
} catch (e) {
console.log(`[Account] Error sending email verification`, e);
console.log(`[Account] Error starting email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else {
@@ -468,81 +581,41 @@ const AddEmailDialog = (props) => {
}
};
const checkVerifyEmail = async () => {
try {
setSending(true);
await accountApi.addEmail(email, code);
props.onClose();
} catch (e) {
console.log(`[Account] Error confirming email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else if (e instanceof EmailVerificationCodeInvalidError) {
setError(t("account_basics_emails_dialog_code_invalid"));
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyEmail();
} else {
await checkVerifyEmail();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
{!verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_email_label")}
aria-label={t("account_basics_emails_dialog_email_label")}
placeholder={t("account_basics_emails_dialog_email_placeholder")}
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
fullWidth
variant="standard"
/>
)}
{verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_code_label")}
aria-label={t("account_basics_emails_dialog_code_label")}
placeholder={t("account_basics_emails_dialog_code_placeholder")}
type="text"
value={code}
onChange={(ev) => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
variant="standard"
/>
{sent ? (
<DialogContentText>{t("account_basics_emails_dialog_check_inbox")}</DialogContentText>
) : (
<>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
<TextField
autoFocus
margin="dense"
label={t("account_basics_emails_dialog_email_label")}
aria-label={t("account_basics_emails_dialog_email_label")}
placeholder={t("account_basics_emails_dialog_email_placeholder")}
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
fullWidth
variant="standard"
/>
</>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
</Button>
{sent ? (
<Button onClick={props.onClose}>{t("common_close")}</Button>
) : (
<>
<Button onClick={props.onClose}>{t("common_cancel")}</Button>
<Button onClick={handleSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
{t("account_basics_emails_dialog_verify_button")}
</Button>
</>
)}
</DialogFooter>
</Dialog>
);
@@ -604,22 +677,24 @@ const PhoneNumbers = () => {
return (
<Pref labelId={labelId} title={t("account_basics_phone_numbers_title")} description={t("account_basics_phone_numbers_description")}>
<div aria-labelledby={labelId}>
{account?.phone_numbers?.map((phoneNumber) => (
<Chip
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{phoneNumber}</span>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(phoneNumber)}
onDelete={() => handleDelete(phoneNumber)}
/>
))}
{!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<AddIcon />
</IconButton>
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.75 }}>
{account?.phone_numbers?.map((phoneNumber) => (
<Chip
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{phoneNumber}</span>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(phoneNumber)}
onDelete={() => handleDelete(phoneNumber)}
/>
))}
{!account?.phone_numbers && <em>{t("account_basics_phone_numbers_no_phone_numbers_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<AddIcon />
</IconButton>
</Box>
</div>
<AddPhoneNumberDialog key={`addPhoneNumberDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
@@ -697,7 +772,7 @@ const AddPhoneNumberDialog = (props) => {
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
@@ -711,7 +786,7 @@ const AddPhoneNumberDialog = (props) => {
type="tel"
value={phoneNumber}
onChange={(ev) => setPhoneNumber(ev.target.value)}
inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }}
slotProps={{ htmlInput: { inputMode: "tel", pattern: "+[0-9]*" } }}
variant="standard"
sx={{ flexGrow: 1 }}
/>
@@ -742,7 +817,7 @@ const AddPhoneNumberDialog = (props) => {
value={code}
onChange={(ev) => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }}
variant="standard"
/>
)}
@@ -1321,7 +1396,7 @@ const DeleteAccountDialog = (props) => {
await accountApi.delete(password);
await db().delete();
console.debug(`[Account] Account deleted`);
await session.resetAndRedirect(routes.app);
await session.resetAndRedirect(routes.app, { fade: true });
} catch (e) {
console.log(`[Account] Error deleting account`, e);
if (e instanceof IncorrectPasswordError) {
+5
View File
@@ -0,0 +1,5 @@
import { createContext } from "react";
const AccountContext = createContext(null);
export default AccountContext;
+45 -4
View File
@@ -6,15 +6,19 @@ import { useLocation, useNavigate } from "react-router-dom";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from "@mui/icons-material/Notifications";
import NotificationsOffIcon from "@mui/icons-material/NotificationsOff";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useTranslation } from "react-i18next";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import { Logout, Person, Settings } from "@mui/icons-material";
import Logout from "@mui/icons-material/Logout";
import Person from "@mui/icons-material/Person";
import Settings from "@mui/icons-material/Settings";
import session from "../app/Session";
import logo from "../img/ntfy.svg";
import subscriptionManager from "../app/SubscriptionManager";
import routes from "./routes";
import db from "../app/db";
import { topicDisplayName } from "../app/utils";
import { fadeNavigate } from "../app/transition";
import Navigation from "./Navigation";
import accountApi from "../app/AccountApi";
import PopupMenu from "./PopupMenu";
@@ -88,6 +92,7 @@ const ActionBar = (props) => {
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
{isLaunchedPWA && <ReloadIcon />}
{props.selected && <SettingsIcons subscription={props.selected} onUnsubscribe={props.onUnsubscribe} />}
<ProfileIcon />
</Toolbar>
@@ -124,6 +129,31 @@ const SettingsIcons = (props) => {
);
};
// ReloadIcon hard-refreshes the app. A plain reload would just serve the precached PWA shell,
// so we first purge the service worker caches to force fresh assets from the network.
const ReloadIcon = () => {
const { t } = useTranslation();
const handleReload = async () => {
try {
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
} catch (e) {
console.warn("[ActionBar] Error clearing caches during reload", e);
} finally {
window.location.reload();
}
};
return (
<IconButton color="inherit" size="large" edge="end" onClick={handleReload} aria-label={t("action_bar_reload")}>
<RefreshIcon />
</IconButton>
);
};
const ProfileIcon = () => {
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState(null);
@@ -143,7 +173,7 @@ const ProfileIcon = () => {
await accountApi.logout();
await db().delete();
} finally {
await session.resetAndRedirect(routes.app);
await session.resetAndRedirect(routes.app, { fade: true });
}
};
@@ -155,12 +185,23 @@ const ProfileIcon = () => {
</IconButton>
)}
{!session.exists() && config.enable_login && (
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}>
<Button
color="inherit"
variant="text"
onClick={() => fadeNavigate(navigate, routes.login)}
sx={{ m: 1 }}
aria-label={t("action_bar_sign_in")}
>
{t("action_bar_sign_in")}
</Button>
)}
{!session.exists() && config.enable_signup && (
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
<Button
color="inherit"
variant="outlined"
onClick={() => fadeNavigate(navigate, routes.signup)}
aria-label={t("action_bar_sign_up")}
>
{t("action_bar_sign_up")}
</Button>
)}
+69 -28
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { createContext, Suspense, useContext, useEffect, useState, useMemo } from "react";
import { Suspense, useContext, useEffect, useState, useMemo } from "react";
import { Box, Toolbar, CssBaseline, Backdrop, CircularProgress, useMediaQuery, ThemeProvider, createTheme } from "@mui/material";
import { useLiveQuery } from "dexie-react-hooks";
import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom";
@@ -20,15 +20,19 @@ import Messaging from "./Messaging";
import Login from "./Login";
import Signup from "./Signup";
import Account from "./Account";
import EmailVerify from "./EmailVerify";
import PasswordReset from "./PasswordReset";
import PasswordResetRequest from "./PasswordResetRequest";
import initI18n from "../app/i18n"; // Translations!
import prefs from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider";
import session from "../app/Session";
import AccountContext from "./AccountContext";
import { PrefCacheProvider } from "./PrefCache";
import hideSplash from "../app/splash";
initI18n();
export const AccountContext = createContext(null);
const App = () => {
const { i18n } = useTranslation();
const languageDir = i18n.dir();
@@ -36,16 +40,29 @@ const App = () => {
const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]);
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const themePreference = useLiveQuery(() => prefs.theme());
const theme = React.useMemo(
() => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }),
[prefersDarkMode, themePreference, languageDir]
);
const isDark = darkModeEnabled(prefersDarkMode, themePreference);
const theme = React.useMemo(() => createTheme({ ...(isDark ? darkTheme : lightTheme), direction: languageDir }), [isDark, languageDir]);
useEffect(() => {
document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language));
document.dir = languageDir;
}, [i18n.language, languageDir]);
// Keep the <html> background in sync with the theme once loaded. The splash script sets the
// initial class; don't override it while the preference is still loading.
useEffect(() => {
if (themePreference === undefined) {
return;
}
document.documentElement.classList.toggle("dark", isDark);
}, [isDark, themePreference]);
// Safety net: hide the splash even if no route does (e.g. an unmatched path).
useEffect(() => {
const timer = setTimeout(() => hideSplash(), 3000);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!session.exists() && config.require_login && window.location.pathname !== routes.login) {
window.location.href = routes.login;
@@ -61,8 +78,13 @@ const App = () => {
<CssBaseline />
<ErrorBoundary>
<Routes>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route element={<AuthLayout />}>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
<Route path={routes.passwordReset} element={<PasswordReset />} />
<Route path={routes.emailVerify} element={<EmailVerify />} />
</Route>
<Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} />
@@ -86,6 +108,12 @@ const updateTitle = (newNotificationsCount) => {
updateFavicon(newNotificationsCount);
};
// Auth pages render synchronously, so the splash can be removed on mount.
const AuthLayout = () => {
useEffect(() => hideSplash(), []);
return <Outlet />;
};
const Layout = () => {
const params = useParams();
const { account, setAccount } = useContext(AccountContext);
@@ -93,6 +121,9 @@ const Layout = () => {
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
// Preloaded here so the All view (and single topics, via filter) have data on mount -- no empty
// frame when switching.
const allNotifications = useLiveQuery(() => subscriptionManager.getAllNotifications());
const webPushTopics = useWebPushTopics();
const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
@@ -107,27 +138,37 @@ const Layout = () => {
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
// Hide the splash only once subscriptions have loaded, so the nav/list don't pop in empty-then-filled.
useEffect(() => {
if (subscriptions !== undefined) {
hideSplash();
}
}, [subscriptions]);
return (
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
}}
<PrefCacheProvider>
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
allNotifications,
}}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
</PrefCacheProvider>
);
};

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