Compare commits

...
Author SHA1 Message Date
multipleof4 e2f4fc7469 Docs: Markdown is no longer web app only
Android renders Markdown since 1.17.8 (#310).
2026-09-25 09:34:04 -07:00
binwiederhier 4f52663dda Fix exact count that caused the RO replicate to fall over... 2026-09-23 23:13:22 +00:00
binwiederhier 10cb6506f8 Bump 2026-08-27 20:16:48 +00:00
binwiederhier 7826c86f67 Remove message-poll-limit again, hardcode title/tags/etc limits, count polls toward bandwidth limit 2026-08-27 19:42:30 +00:00
binwiederhier cae7816e5d Add message-poll-limit ... 2026-08-27 16:38:21 +00:00
binwiederhier a0a0a5fde3 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-08-27 14:14:03 +00:00
binwiederhier fbcd4b6290 Expands bandwidth limit to also include excessive polling 2026-08-27 14:12:01 +00:00
Philipp C. Heckel f1bdb6bfe1 Merge pull request #1909 from Pimak/add-ntfy-logging-library
docs: add ntfy-logging to the Libraries section
2026-08-20 10:18:56 -04:00
PimakandClaude Opus 5 2f53e15a20 docs: add ntfy-logging to the Libraries section
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:16:24 +02:00
Philipp C. Heckel 4c2b69e059 Merge pull request #1885 from binwiederhier/revert-1882-main
Revert "add tzdata to docker arm build"
2026-08-04 07:24:30 +02:00
Philipp C. Heckel ea6b1ca520 Revert "add tzdata to docker arm build" 2026-08-04 01:24:15 -04:00
binwiederhier 423063893d Bump install notes 2026-08-04 07:05:29 +02:00
Philipp C. Heckel 143b9fb55c Merge pull request #1847 from nexus-uw/patch-2
Add action and template directories to Dockerfile-build
2026-08-04 06:47:50 +02:00
Philipp C. Heckel 3104ad20e2 Merge pull request #1820 from houllette/add-ex-ntfy-library
Add ex_ntfy (Elixir library) to integrations
2026-08-04 06:47:03 +02:00
Philipp C. Heckel 00e3c1351a Merge pull request #1880 from rubixvi/patch-1
remove 404 abandoned project
2026-08-04 06:46:34 +02:00
Philipp C. Heckel c23dc0b30c Merge pull request #1882 from 0xpsyduck/main
add tzdata to docker arm build
2026-08-04 06:46:13 +02:00
Philipp C. Heckel 01af61d228 Merge pull request #1884 from binwiederhier/dependabot/github_actions/all-85123b4e12
Bump docker/login-action from 4.4.0 to 4.5.2 in the all group across 1 directory
2026-08-04 06:40:10 +02:00
binwiederhier 9328f1f3c8 Bump fast-uri 2026-08-03 23:07:09 +02:00
dependabot[bot] 61873b593f Bump docker/login-action in the all group across 1 directory
Bumps the all group with 1 update in the / directory: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.4.0 to 4.5.2
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...371161bbe7024a29a25c5e19bfcbc0804fe9ad2c)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 20:41:24 +00:00
binwiederhier 9dc30e4a97 Update GH actions 2026-08-03 22:39:01 +02:00
binwiederhier cd333f130f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-08-03 18:38:38 +02:00
binwiederhier f42326605e Update deps 2026-08-03 18:38:29 +02:00
binwiederhier 7ed7fea081 Limit memory usage in templates 2026-08-03 18:34:29 +02:00
psyduck 01c13e186a add tzdata to docker arm build 2026-08-03 07:39:54 +00:00
binwiederhier 0ecba37334 Combine message dispatching into a dispatch function 2026-08-03 08:11:51 +02:00
Vincent Vu fc808db251 remove 404 project
Removed duplicate entry for 'ntfy-desktop' and updated the list of integrations.
2026-08-02 19:22:41 +10:00
Philipp C. Heckel dc11655153 Merge pull request #1874 from binwiederhier/schema-compare-tests
Schema compare tests
2026-07-29 08:10:20 +02:00
binwiederhier bd96177fdf Move pg tests to its own file 2026-07-29 08:05:50 +02:00
binwiederhier a3d43190c9 Harden migration 2026-07-29 07:25:12 +02:00
binwiederhier 95ad323d1c Schema compare tests 2026-07-29 07:12:28 +02:00
Philipp C. Heckel e7efdaeb3b Merge pull request #1873 from binwiederhier/user-schema-migration
Use schema/ package in user/ package
2026-07-29 06:45:43 +02:00
binwiederhier 244a9bc06d Use schema/ package in user/ package 2026-07-29 06:23:24 +02:00
Philipp C. Heckel 5e13ca05d5 Merge pull request #1871 from binwiederhier/message-cache-migration2
Use schema migration lib for message cache
2026-07-28 23:02:29 +02:00
binwiederhier fdaf3316a0 Use schema migration lib for message cache 2026-07-28 22:22:13 +02:00
Philipp C. Heckel 310a5aa8df Merge pull request #1868 from binwiederhier/schema-migration
Schema migration library
2026-07-28 20:49:35 +02:00
Edgars Andersons 0ff1cd5bab Translated using Weblate (Latvian)
Currently translated at 26.7% (123 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/lv/
2026-07-28 14:02:00 +00:00
binwiederhier 4abdeb8d57 Comment 2026-07-28 07:34:27 +02:00
binwiederhier 7fb1d25740 Restructure a little 2026-07-27 23:48:43 +02:00
binwiederhier ef121a3f6c Schema migration 2026-07-27 17:51:32 +02:00
Vadym Nekhai 4a0f66e258 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-26 13:02:00 +02:00
Oğuz Ersen 53bbd12cd7 Translated using Weblate (Turkish)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2026-07-26 13:01:54 +02:00
Vadym Nekhai e620fbe95b Translated using Weblate (Ukrainian)
Currently translated at 98.2% (451 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-24 10:50:01 +02:00
Joker88 b703627d7f Translated using Weblate (Russian)
Currently translated at 88.0% (404 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2026-07-24 10:50:01 +02:00
Vadym Nekhai 6f4f9e6407 Translated using Weblate (Ukrainian)
Currently translated at 96.9% (445 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-23 17:01:27 +02:00
binwiederhier f2d5c1ce6c Do not include secrets in the config hash 2026-07-23 08:13:27 +02:00
binwiederhier 7680cb4906 Ban-feed 2026-07-20 23:49:17 +02:00
binwiederhier 706fa3b491 Remove "experimental" from postgres option 2026-07-20 23:48:17 +02:00
binwiederhier 2bc145f3ae Merge branch 'release-2.26.x' 2026-07-20 23:42:03 +02:00
binwiederhier 311138ef7b Derp 2026-07-20 23:23:42 +02:00
binwiederhier f6b03b44dd Bump release notes 2026-07-20 23:10:22 +02:00
binwiederhier c674985699 Redo the banning logic, ban.Service 2026-07-20 21:34:47 +02:00
binwiederhier 469d263a5c Hotfix: Add ban-file/ban-threshold/ban-weights as a more lightweight mechanism abuse counter 2026-07-18 08:41:25 +02:00
binwiederhier f8d2fcd7a6 Move metrics to metrics/ pacakge 2026-07-17 22:08:21 +02:00
binwiederhier ac63a2eea0 Move Twilio to twilio/ package 2026-07-17 13:48:48 +02:00
Simon Ramsay 07e1260b89 Add action and template directories to Dockerfile-build
Added new directories for action and template to the build process.
2026-07-16 20:47:51 -07:00
dashhrafa af4d85ec03 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (459 of 459 strings)

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #733

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 21:44:37 +00:00
binwiederhier d1696ac5b6 Docs: add topic generator 2026-06-08 21:52:54 -04:00
Hunter Kehoe 737163ba59 update release notes 2026-06-06 10:42:25 -06:00
Hunter Kehoe 26bc28ae24 support clear|read via GET 2026-06-06 10:38:09 -06:00
Hunter Kehoe 88fff8264b support delete via GET 2026-06-06 10:37:46 -06:00
Dimitriy Goloborodko 9cc6124f18 Play notification sounds from the page ignoring Notification API support 2026-06-06 20:06:02 +10:00
binwiederhier 5ff460f1dc Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-06-05 13:18:54 -04:00
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
binwiederhier 9302697a07 Install and release notes 2026-05-17 21:21:09 -04:00
binwiederhier 36ba1650ed Merge branch 'main' of github.com:binwiederhier/ntfy 2026-05-17 17:28:06 -04:00
binwiederhier a1d880aab9 Make logs easier to read 2026-05-17 17:27:38 -04:00
binwiederhier acb4c1b3cc Add visitor-topic-creation-limit-* options 2026-05-17 11:33:07 -04:00
Philipp C. Heckel 19bcf0f658 Merge pull request #1718 from Ryan02I5/codex/oci-notifications-ntfy-relay
Add OCI Notifications relay integration entry
2026-05-17 10:33:30 -04:00
binwiederhier 29113402ce Low: Fix self-XSS in SVGs 2026-05-17 09:57:41 -04:00
binwiederhier 160c916ce0 Release notes 2026-05-17 09:32:38 -04:00
binwiederhier 578abdfe08 Make sure fallback to language works 2026-05-17 09:30:44 -04:00
binwiederhier b5ff765bb7 Bump 2026-05-17 09:01:39 -04:00
binwiederhier f24bf7b51a Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-05-17 08:59: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
Sam Smith df2ce34dc0 Translated using Weblate (Russian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2026-05-13 15:11:32 +00:00
binwiederhier d6397fc5e5 Bump 2026-05-12 20:42:17 -04:00
binwiederhier c15a242d1a Release notes 2026-05-12 20:37:30 -04:00
Philipp C. Heckel c6e252b3d6 Merge pull request #1737 from rubixvi/patch-1
add tauri based desktop client integration
2026-05-12 08:08:04 -04:00
Vincent Vu bdad542fc0 Add Ntfy App to integrations list 2026-05-12 21:30:27 +10:00
Kristijan \"Fremen\" Velkovski 3758472345 Translated using Weblate (Macedonian)
Currently translated at 24.0% (98 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-05-07 13:11:34 +00:00
Vadym Nekhai 3af7087af3 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-05-07 13:11:33 +00:00
Darjan Zlobec 1af07233a9 Translated using Weblate (Slovenian)
Currently translated at 28.0% (114 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sl/
2026-05-05 23:11:31 +02:00
Darjan Zlobec c037e78bd6 Translated using Weblate (Slovenian)
Currently translated at 0.4% (2 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sl/
2026-05-04 23:02:30 +02:00
Darjan Zlobec 8f2f69a512 Added translation using Weblate (Slovenian) 2026-05-04 22:50:48 +02:00
Brekit a5cf3c0b74 Added translation using Weblate (Belarusian) 2026-04-30 23:31:11 +02: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
binwiederhier 802c0a4c30 Bump 2026-04-27 16:49:33 -04:00
Ryan02I5 33a67cf05b Add OCI Notifications ntfy relay integration 2026-04-27 16:22:26 +09:00
Jithin James 8fb7f61dea Added translation using Weblate (Malayalam) 2026-04-24 06:32:51 +02:00
Gringo 4fbb8441ee Translated using Weblate (Italian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/it/
2026-04-24 06:32:51 +02:00
Philipp C. Heckel 9fc96bfb8f Merge pull request #1712 from binwiederhier/dependabot/npm_and_yarn/web/i18next-http-backend-3.0.5
Bump i18next-http-backend from 1.4.5 to 3.0.5 in /web
2026-04-22 20:42:39 -04:00
dependabot[bot] fe8c9b8f2c Bump i18next-http-backend from 1.4.5 to 3.0.5 in /web
Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 1.4.5 to 3.0.5.
- [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-http-backend/compare/v1.4.5...v3.0.5)

---
updated-dependencies:
- dependency-name: i18next-http-backend
  dependency-version: 3.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-22 17:43:47 +00:00
binwiederhier 5ad2431dc3 Bump 2026-04-21 11:01:17 -04:00
binwiederhier 2401e183d2 Bump 2026-04-21 10:49:24 -04:00
binwiederhier fa83d68754 Tighten web push endpoint allow list 2026-04-21 10:40:52 -04:00
binwiederhier 1956c88392 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-04-21 10:07:39 -04:00
usefulish eefd3d1a54 Translated using Weblate (English (United Kingdom))
Currently translated at 0.9% (4 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/en_GB/
2026-04-15 19:09:49 +02:00
usefulish e10ece9715 Added translation using Weblate (English (United Kingdom)) 2026-04-14 18:31:09 +02:00
ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) ac09f9802b Translated using Weblate (Latvian)
Currently translated at 29.7% (121 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/lv/
2026-04-13 12:09:54 +02:00
ShipItAndPray 82e9dfe8f1 Include Darwin in the serve Unix build 2026-04-12 10:22:25 -05:00
ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) 6e90b16d0d Added translation using Weblate (Latvian) 2026-04-12 10:43:38 +02:00
binwiederhier 6cfadf9681 Bump release notes 2026-04-11 15:24:28 -04:00
Philipp C. Heckel 59229adf31 Merge pull request #1695 from ShipItAndPray/fix/account-token-missing-last-access
Avoid crashing /account for tokens without a last access time
2026-04-11 15:21:59 -04:00
binwiederhier 517bc45f1c Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-04-11 15:14:58 -04:00
binwiederhier e1dde9f385 Release notes 2026-04-11 15:14:52 -04:00
Philipp C. Heckel a063e2bb35 Merge pull request #1694 from ShipItAndPray/fix/token-expiration-never-edit
Allow edited access tokens to be set to never expire
2026-04-11 15:05:59 -04:00
Philipp C. Heckel 79e70c9f62 Merge pull request #1691 from binwiederhier/dependabot/npm_and_yarn/web/vite-6.4.2
Bump vite from 6.4.1 to 6.4.2 in /web
2026-04-11 15:01:18 -04:00
binwiederhier 55b27260cf Update release notes 2026-04-10 22:18:00 -04:00
binwiederhier 11ff36a19e Release notes 2026-04-10 21:53:48 -04:00
Kajus S 5c9e29ba1c Added translation using Weblate (Lithuanian) 2026-04-09 21:26:11 +02:00
ShipItAndPray 5d93cb400a Avoid formatting missing account token access times 2026-04-09 14:22:38 -05:00
ShipItAndPray 4b0a4eee3b web: allow setting token expiration to never when editing 2026-04-09 11:57:57 -05:00
Cairo Braga 795ef9da1c Translated using Weblate (Spanish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2026-04-09 17:12:51 +02:00
Cairo Braga 7e16203065 Translated using Weblate (Catalan)
Currently translated at 10.3% (42 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/
2026-04-09 17:12:51 +02:00
Cairo Braga c11991a91d Translated using Weblate (Portuguese)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-04-09 17:12:50 +02:00
Cairo Braga 85cc652449 Translated using Weblate (Spanish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2026-04-08 16:09:54 +02:00
Cairo Braga ec494aead3 Translated using Weblate (Catalan)
Currently translated at 5.4% (22 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/
2026-04-08 16:09:53 +02:00
Cairo Braga 1b948e9dfa Translated using Weblate (Portuguese)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/
2026-04-08 16:09:51 +02:00
Cairo Braga ad7dc1bf5e Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2026-04-08 16:09:49 +02:00
dependabot[bot] 4c3968eaba Bump vite from 6.4.1 to 6.4.2 in /web
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 04:29:08 +00:00
Gringo cc61d79313 Translated using Weblate (Italian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/it/
2026-04-06 04:09:48 +02:00
binwiederhier 9a2b93f7b2 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-04-05 07:26:51 -04:00
binwiederhier 05e0e4ed05 Bump 2026-04-03 21:28:53 -04:00
Philipp C. Heckel a47835f21f Merge pull request #1683 from jejo86/patch-1
Restore VirtualHost Example for Apache HTTP Server 2.4.46 and Earlier
2026-04-03 21:28:34 -04:00
Cairo Braga 36b76eb318 Translated using Weblate (Spanish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2026-04-01 00:09:51 +02:00
Cairo Braga eb624f4bc5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2026-04-01 00:09:50 +02:00
Daniel Maganha 4417b951cc Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2026-04-01 00:09:49 +02:00
binwiederhier 17ec63df77 Update privacy policy to reflect email change 2026-03-30 20:24:14 -04:00
Philipp C. Heckel 7ce5e8adda Merge pull request #1681 from binwiederhier/email-verification
Add email verification
2026-03-30 16:39:02 -04:00
binwiederhier 6219784aae Bump 2026-03-30 16:38:01 -04:00
binwiederhier 51da5e0f77 Review 2026-03-30 16:33:19 -04:00
binwiederhier e57ef84f13 Fix limits for anon users 2026-03-30 16:06:53 -04:00
binwiederhier 4c6225e311 Docs 2026-03-30 12:38:47 -04:00
binwiederhier 07b3812549 Docs, fix generator 2026-03-30 12:37:07 -04:00
binwiederhier 63ec73a319 Tests 2026-03-30 12:21:19 -04:00
jejo86 ffa22fc24b Restore VirtualHost Example for Apache HTTP Server 2.4.46 and Earlier
In commit 76667ffc the VirtualHost example configuration was adapted to use the latest ProxyPass parameter 'upgrade'.
Restore the previous example for Apache HTTP Server 2.4.46 and earlier, which do not have this function yet without replacing the more current solution, but having both side-by-side. 

https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#protoupgrade
2026-03-30 15:55:05 +02:00
binwiederhier bdea8c314f Refine docs and bump deps 2026-03-30 09:00:47 -04:00
binwiederhier 3e634e0a5a Refine 2026-03-30 08:51:18 -04:00
binwiederhier 6aebc5c677 Refine 2026-03-30 08:26:56 -04:00
binwiederhier 61dd788dac WIP: Email verification 2026-03-29 22:47:38 -04:00
Philipp C. Heckel 266d0b9d37 Merge pull request #1679 from binwiederhier/attachment-no-http2
Attachment: Disable HTTP/2
2026-03-27 14:21:06 -04:00
binwiederhier 835d1faac4 Bump 2026-03-27 14:14:31 -04:00
binwiederhier 67fc7fe96a Disable HTTP2 for S3 backend with ?disable_http2=true option 2026-03-27 13:59:07 -04:00
binwiederhier 92fa88cf12 Merge branch 'main' into attachment-no-http2 2026-03-27 10:04:42 -04:00
binwiederhier b5fee121d7 Empty commit 2026-03-26 22:58:24 -04:00
binwiederhier e1a344339f Dummy 2026-03-26 22:20:07 -04:00
binwiederhier ae1ecfa1e9 BUmp 2026-03-26 22:17:54 -04:00
binwiederhier 874bdcf9f1 No HTTP/2 2026-03-26 20:47:06 -04:00
binwiederhier 2235d44726 Update changelog 2026-03-26 13:39:04 -04:00
binwiederhier 27bbb10a31 Bump 2026-03-26 11:13:43 -04:00
binwiederhier be4134fc3b Try to fix flaky again 2026-03-26 10:49:39 -04:00
binwiederhier e19ba059b5 Make attachment.Close() synchronous 2026-03-26 09:57:06 -04:00
binwiederhier 11a14d8fe7 Fix CI 2026-03-26 09:36:34 -04:00
binwiederhier 3759ff26b4 "npm ci" 2026-03-26 09:28:05 -04:00
binwiederhier 136b50f926 I'm tired and GitHub Actions hates me. I'll release this tomorrow ... 2026-03-25 21:35:51 -04:00
binwiederhier 2770f65027 Arrrg 2026-03-25 21:12:27 -04:00
binwiederhier db6f813386 Release notes derp 2026-03-25 21:03:49 -04:00
binwiederhier 15d963cb53 Fix flaky test due to new attachment expiry cap logic 2026-03-25 21:00:58 -04:00
binwiederhier a2206dba9f Fix races in tests 2026-03-25 20:47:20 -04:00
binwiederhier d159580ecf Bump 2026-03-25 20:34:48 -04:00
binwiederhier 0de9dc11ad Bump 2026-03-25 20:29:12 -04:00
binwiederhier f790143b0b Refined wording, review 2026-03-25 17:35:24 -04:00
binwiederhier 42b0254c9b Merge branch 'main' of github.com:binwiederhier/ntfy into ageru/main 2026-03-25 17:26:49 -04:00
Philipp C. Heckel d183af61fa Merge pull request #1672 from binwiederhier/attachment-fixes
Attachment fixes to address inconsistencies between DB and backend store
2026-03-25 17:26:18 -04:00
binwiederhier f256a4101b Fix test 2026-03-25 17:20:02 -04:00
binwiederhier 3ff8bacc45 Update changelog 2026-03-25 17:09:44 -04:00
binwiederhier ca59cfc1e1 Words 2026-03-25 17:01:29 -04:00
binwiederhier 071543efda Fixes 2026-03-25 15:28:23 -04:00
ageru e22a77d4bb Rename ntfy-server.openrc to ntfy.openrc 2026-03-24 23:38:16 +01:00
ageru e0362dce36 install.md - Install ntfy server service manually
Rework of steps to install ntfy server service on systemd and OpenRC
2026-03-24 23:36:19 +01:00
binwiederhier e55d1cee6b Attachment fixes to address inconsistencies between DB and backend store 2026-03-24 17:37:50 -04:00
ageru 676f1ff1cb Merge branch 'binwiederhier:main' into main 2026-03-24 22:37:38 +01:00
binwiederhier dd760f16f7 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-03-23 13:05:09 -04:00
binwiederhier d00277107a Release notes 2026-03-23 13:04:47 -04:00
binwiederhier b95efe8dd3 Tighten message IDs in attachments 2026-03-23 12:54:13 -04:00
binwiederhier 075f2ffa15 Refine sync() to properly update sizes 2026-03-23 12:44:40 -04:00
Philipp C. Heckel 2f6a044c34 Merge pull request #1656 from binwiederhier/attachment-s3
S3 attachment storage
2026-03-22 21:33:07 -04:00
binwiederhier e6192c94bd Docs updates 2026-03-22 21:24:43 -04:00
binwiederhier e87a3e62fe Fix workflows to not double run 2026-03-22 21:11:21 -04:00
binwiederhier 233ec0973d bump wait time 2026-03-22 21:01:33 -04:00
binwiederhier 69cc80ec1e Add comments about AWS S3 2026-03-22 20:52:25 -04:00
binwiederhier 4d07897d2d RWMutex 2026-03-22 16:20:45 -04:00
binwiederhier a04128520d Run S3 tests in CI 2026-03-22 16:17:17 -04:00
binwiederhier ef051afc09 Update base-url in examples 2026-03-22 16:06:57 -04:00
binwiederhier f8397838e6 Update docs 2026-03-22 16:05:48 -04:00
binwiederhier 59ec76e8b2 Fix brittle tests, move delete batching into client package, run s3 tests against real bucket 2026-03-22 15:10:28 -04:00
binwiederhier 9b3408d4c4 Merge branch 'main' of github.com:binwiederhier/ntfy into attachment-s3 2026-03-22 08:43:45 -04:00
binwiederhier 536c6f5807 More consistent logging 2026-03-22 08:38:41 -04:00
binwiederhier 56b63c475c Remove MinIO mention 2026-03-22 08:28:18 -04:00
binwiederhier fa33d63138 More tests 2026-03-22 08:26:18 -04:00
binwiederhier f76135c5ab Large objects test 2026-03-22 08:23:46 -04:00
binwiederhier f2d4575831 Use real S3 for tests 2026-03-22 08:15:23 -04:00
Nihal Gonsalves 6a7c1c47aa fix: token extension for PWAs 2026-03-22 12:27:39 +01:00
binwiederhier ad501feab1 Rewrite tests 2026-03-21 21:59:59 -04:00
binwiederhier b81218953a Allow streaming to S3 2026-03-21 21:14:49 -04:00
Philipp C. Heckel 6f47d77200 Merge pull request #1670 from nihalgonsalves/ng/fix-dockerfile-build
build: fix Dockerfile-build, bump EoL go/node
2026-03-21 20:56:42 -04:00
Nihal Gonsalves d5466f30e5 perf: speed up deps using buildkit cache 2026-03-22 01:22:13 +01:00
Nihal Gonsalves 52cad203fa build: fix Dockerfile-build, bump EoL go/node 2026-03-22 00:50:47 +01:00
binwiederhier b3a8f18019 Docs 2026-03-21 17:03:29 -04:00
binwiederhier 78d3138565 Fix flaky test 2026-03-21 16:54:16 -04:00
binwiederhier 6a820b5030 Tags 2026-03-21 16:29:58 -04:00
binwiederhier 1742302f83 More tests and human review 2026-03-21 16:27:41 -04:00
Philipp C. Heckel f25f0e21f0 Merge pull request #1668 from nihalgonsalves/patch-1
Update web app FAQ to include `require-login`
2026-03-21 16:25:08 -04:00
Nihal Gonsalves 22225c5bd0 Update web app FAQ to include require-login 2026-03-21 21:20:15 +01:00
binwiederhier 393f730d11 More manual review and refinement 2026-03-21 16:12:46 -04:00
binwiederhier 02ea09ab0f Refine, manual review, re-org 2026-03-21 15:52:45 -04:00
binwiederhier 039ef19e6c Merge branch 'main' into attachment-s3 2026-03-19 22:44:30 -04:00
binwiederhier f9974d8a2f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-03-19 22:44:15 -04:00
binwiederhier 0d162b984b Bump 2026-03-19 22:44:08 -04:00
binwiederhier 1f270b68e0 Simplify a little, manual review 2026-03-19 22:42:38 -04:00
binwiederhier d86e20173c Move stuff around 2026-03-19 21:46:52 -04:00
binwiederhier a1b403d23c Remove s3 config option, reduce size when removing files 2026-03-19 21:11:36 -04:00
Guillaume Petit 5ca0cf9912 Translated using Weblate (French)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2026-03-19 18:09:47 +01:00
binwiederhier ef314960d0 Refactor 2026-03-17 20:53:41 -04:00
binwiederhier cffa57950a Logs 2026-03-17 16:25:45 -04:00
binwiederhier a47d692cbf Fix bug 2026-03-17 07:50:28 -04:00
binwiederhier 6b11bc7468 Merge branch 'main' into attachment-s3 2026-03-16 21:11:38 -04:00
binwiederhier d9efe50848 Email validation 2026-03-16 21:03:33 -04:00
binwiederhier 2ad78edca1 Release notes 2026-03-16 20:13:39 -04:00
binwiederhier 86015e100c Multipart upload 2026-03-16 20:00:19 -04:00
ageru e8199fa6b5 install.md - Install ntfy server service manually
First draft - Add manual steps to install ntfy server service on systemd and OpenRC
2026-03-16 23:30:21 +01:00
ageru c29a7bc8cc Merge branch 'binwiederhier:main' into main 2026-03-16 22:35:14 +01:00
binwiederhier 458fbad770 Merge branch 'main' into attachment-s3 2026-03-16 15:53:21 -04:00
binwiederhier 9b1a32ec56 Refine 2026-03-16 15:52:57 -04:00
binwiederhier 3d9ce69042 PG races 2026-03-16 15:48:36 -04:00
binwiederhier 59ce581ba2 Fix postgres primary/replica races 2026-03-16 11:21:21 -04:00
binwiederhier df82fdf44c Add HTTP 413 to normal errors to not log 2026-03-16 10:27:23 -04:00
binwiederhier 3a37ea32f7 Webpush: Fix FK issue with Postgres 2026-03-16 10:24:16 -04:00
binwiederhier 790ba243c7 S3 WIP 2026-03-16 09:48:26 -04:00
binwiederhier 4487299a80 Merge branch 'main' into attachment-s3 2026-03-16 05:43:20 -04:00
binwiederhier 6b38acb23a Route authorization query to read-only database replica to reduce primary database load 2026-03-15 22:01:19 -04:00
binwiederhier f5c255c53c Grr 2026-03-15 21:17:58 -04:00
binwiederhier fd0a49244e Disable test temporarily 2026-03-15 21:13:12 -04:00
binwiederhier 4699ed3ffd Fix UTF-8 insert failures in Postgres 2026-03-15 21:03:18 -04:00
Philipp C. Heckel 1afb99db67 Merge pull request #1658 from BradStaton/1657-postgresql-urls
Support `postgresql://` and `postgres://` URLs
2026-03-15 20:45:08 -04:00
binwiederhier 66208e6f88 Pre-import 2026-03-15 20:25:22 -04:00
BradStaton ce24594c32 Update serve.go
Support multiple postgres connection URL formats
2026-03-15 16:22:22 -04:00
binwiederhier 888850d8bc Add blurp 2026-03-15 10:29:07 -04:00
binwiederhier be09acd411 Bump 2026-03-15 10:26:03 -04:00
binwiederhier bf19a5be2d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-03-15 10:12:54 -04:00
binwiederhier b4ec6fa8df AWS deps.. 2026-03-15 10:12:23 -04:00
binwiederhier d517ce4a2a WIP: S3 2026-03-14 21:10:46 -04:00
BonifacioCalindoro fd8f356d1f Translated using Weblate (Spanish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2026-03-15 01:09:47 +01:00
binwiederhier 3296d158c5 Release notes 2026-03-14 15:07:11 -04:00
binwiederhier 45f045a5a4 Make config generator work on mobile 2026-03-14 14:39:51 -04:00
binwiederhier f7b6e9bbe3 Merge branch 'config-generator' 2026-03-14 14:21:18 -04:00
binwiederhier 22868f4742 Derp 2026-03-14 14:21:09 -04:00
Philipp C. Heckel 3801a28958 Merge pull request #1654 from binwiederhier/config-generator
Config generator
2026-03-14 14:16:51 -04:00
binwiederhier 2bf8f6271b Review 2026-03-14 14:15:46 -04:00
binwiederhier 13be9747e4 Security reivew 2026-03-14 13:56:43 -04:00
binwiederhier 26dd017401 Merge branch 'main' of github.com:binwiederhier/ntfy into config-generator 2026-03-14 13:47:42 -04:00
binwiederhier d00cd64220 Add admin user 2026-03-14 13:03:36 -04:00
binwiederhier fab08e862d More refining for config generator 2026-03-14 12:56:26 -04:00
binwiederhier 143935b917 More refining 2026-03-14 08:42:07 -04:00
Philipp C. Heckel a82ede8a14 Merge pull request #1648 from binwiederhier/postgres-replica
Add PostgreSQL read-only replica support
2026-03-12 21:28:38 -04:00
binwiederhier 8a34dfe3f8 Move things, rename things 2026-03-12 21:17:30 -04:00
binwiederhier 270fec51a6 Bump 2026-03-11 22:12:10 -04:00
binwiederhier 9eaadd74cf Log 2026-03-11 22:09:00 -04:00
binwiederhier 1f483dcbd3 Remove consts 2026-03-11 21:54:35 -04:00
binwiederhier 85bdfc61ce Refine, log unhealthy replica 2026-03-11 21:07:58 -04:00
binwiederhier ac65df1e83 Move auth queries to primary, redo health check loop 2026-03-11 20:26:29 -04:00
ageru 78f0593abe Merge pull request #1 from ageru/add-openrc-init-file-for-ntfy-server
Create Init service file for OpenRC
2026-03-11 22:00:03 +01:00
ageru aca58f040f Create Init service file for OpenRC
This in an init file for OpenRC systems.

It should be equivalent in features to the current systemd file, with 2 deliberate changes:
- removed "no-log-dates", as the logs are fine as-is
- Lower nofile limit, as it seems largely sufficient for a self-hosted instance. Feel free to increase to 8192 or 10240 if necessary.

Confirmed functional with Gentoo amd64 and ntfy 2.17.0.
2026-03-11 21:56:14 +01:00
binwiederhier ab33ac7ae5 Refine 2026-03-11 11:58:40 -04:00
binwiederhier f1865749d7 WIP: Postgres read-only replica 2026-03-10 22:17:40 -04:00
binwiederhier 997e20fa3f Better error message for database-url errors 2026-03-10 21:18:34 -04:00
binwiederhier 3402510b47 More config generator 2026-03-10 21:11:27 -04:00
binwiederhier 19d1618bb8 Continued 2026-03-08 22:00:08 -04:00
binwiederhier 612afb1435 Configurator 2026-03-08 19:32:54 -04:00
binwiederhier 2b36ad9eb9 config generator 2026-03-08 18:59:17 -04:00
binwiederhier bcd07115c2 Add tooltips for edit/delete buttons 2026-03-08 18:30:11 -04:00
binwiederhier 109271a930 Avoid playing sound more than every 2s 2026-03-08 10:55:59 -04:00
binwiederhier fcf95dc9b8 Fix release notes 2026-03-07 16:17:00 -05:00
binwiederhier 79c3ab9ecc Bump version 2026-03-07 16:07:38 -05:00
binwiederhier d51465fb6a Release notes 2026-03-07 08:51:57 -05:00
binwiederhier 0b189f65ff Loadtest username/pass 2026-03-06 15:52:53 -05:00
binwiederhier 0d4b1b00e6 Users() optimization to help with startup time 2026-03-06 14:46:53 -05:00
binwiederhier 28c3fd5cbe BUmp 2026-03-06 12:50:49 -05:00
binwiederhier 62bb335675 Grr 2026-03-04 22:42:45 -05:00
binwiederhier 70fb2732af Merge branch 'main' into postgres-support 2026-03-04 22:35:38 -05:00
binwiederhier 8e91e028a0 Fix coverage issue 2026-03-04 22:34:34 -05:00
binwiederhier 6d22f568f9 Merge branch 'main' into postgres-support 2026-03-04 21:07:40 -05:00
binwiederhier 59e6c16633 Fix cov test 2026-03-04 21:07:21 -05:00
binwiederhier 2e0b934bc2 Merge branch 'main' into postgres-support 2026-03-04 20:51:25 -05:00
binwiederhier 4f4a093f8d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-03-04 20:50:28 -05:00
binwiederhier 5610b7c56d Bump 2026-03-04 20:49:18 -05:00
binwiederhier d4038f566c Release notes 2026-03-04 20:48:30 -05:00
binwiederhier bff2b47eb6 Simplify schema reading 2026-03-03 14:52:29 -05:00
binwiederhier 33b19814c7 Transactions 2026-03-03 14:42:36 -05:00
binwiederhier fb26e7ef3a BUmp 2026-03-03 09:42:59 -05:00
binwiederhier 66449bd19b pgimport readme 2026-03-02 20:15:35 -05:00
binwiederhier bedbb121e4 Remove codecov.io 2026-03-02 20:05:29 -05:00
binwiederhier c4b8cfa756 Manual correction 2026-03-02 20:04:03 -05:00
binwiederhier c864a9baeb Put more things in tx 2026-03-02 19:58:26 -05:00
binwiederhier 8afeb813d9 Move OpenPostgres 2026-03-02 19:52:36 -05:00
binwiederhier ea4739f79b Extract ExecTx 2026-03-02 19:45:35 -05:00
binwiederhier 31f0234098 Remove goroutine 2026-03-02 13:28:37 -05:00
binwiederhier 0e6b467cf0 Manual fixes 2026-03-02 12:58:01 -05:00
binwiederhier 11c79a6369 Refine again 2026-03-01 14:24:06 -05:00
binwiederhier 10a6939d8e Rename queries for consistency 2026-03-01 13:47:42 -05:00
binwiederhier 9736973286 REmove store interface 2026-03-01 13:19:53 -05:00
Philipp C. Heckel 941c43c10b Merge pull request #1622 from acress1/patch-1
Update ntfy.tedomum.net URL to ntfy.tedomum.fr
2026-03-01 12:19:44 -05:00
Philipp C. Heckel af76aa011d Merge pull request #1629 from azrikahar/docs-config-access-token
docs: fix references in access token examples
2026-03-01 12:19:10 -05:00
binwiederhier 039566bcaf REmove unused 2026-03-01 11:59:24 -05:00
binwiederhier 544ce112b5 Manual review 2026-03-01 11:44:22 -05:00
binwiederhier c19377109e Small refinements 2026-02-28 21:15:51 -05:00
binwiederhier ccbd02331c Re-add execTx 2026-02-28 19:49:01 -05:00
binwiederhier 542aa403d2 Re-order functions 2026-02-28 19:34:09 -05:00
binwiederhier ebb48e217d Merge user store and manager 2026-02-28 17:35:35 -05:00
binwiederhier 7710ace184 Self-review 2026-02-28 16:13:58 -05:00
azrikahar b937b44f2d undo unrelated formatting changes 2026-02-28 15:06:37 +08:00
azrikahar e618cf1a39 mention token in private instance example 2026-02-28 15:03:09 +08:00
azrikahar e9cf2b5523 align backup-script user references in private instance section 2026-02-28 14:56:40 +08:00
azrikahar c49a8179cf align backup-service user references in token via the config section 2026-02-28 14:55:25 +08:00
Bora Atıcı a1cca7972d Translated using Weblate (Turkish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2026-02-27 09:09:49 +01:00
ezn24 da1c7b1949 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-02-27 09:09:47 +01:00
binwiederhier 5c26e70fe7 Manual review: Make resetUserAccessTx 2026-02-26 17:34:19 -05:00
binwiederhier c66fa92341 Re-add tx integrity 2026-02-26 16:54:56 -05:00
binwiederhier a7d5a9c5d8 Sample 2026-02-23 23:08:13 -05:00
binwiederhier 391cd2c920 Migration tool 2026-02-23 22:44:21 -05:00
binwiederhier 9eec72adcc Fix race tests 2026-02-23 21:39:07 -05:00
binwiederhier 28a436c0d2 Optimizations 2026-02-23 14:11:36 -05:00
binwiederhier b02366b42b Make more consistent 2026-02-23 13:49:54 -05:00
binwiederhier 90d0eca14d Consistency 2026-02-23 11:17:57 -05:00
acress1 0d375d3a08 Update ntfy.tedomum.net URL to ntfy.tedomum.fr 2026-02-23 09:22:38 +01:00
binwiederhier 811c7ae25a TX fixes 2026-02-22 20:39:41 -05:00
binwiederhier 850a9d4cc4 Fix bug with provisioned token removal 2026-02-22 20:28:37 -05:00
binwiederhier 43280fbc0a Performance improvements in pg 2026-02-22 16:21:27 -05:00
binwiederhier 35a54407a8 Manual review 2026-02-22 15:12:54 -05:00
binwiederhier f726cc768e Manual refactor 2026-02-22 14:25:15 -05:00
binwiederhier 5d301e7dce Merge branch 'main' of github.com:binwiederhier/ntfy into postgres-support 2026-02-22 14:15:42 -05:00
binwiederhier 8b12bdeb3a Release notes 2026-02-22 09:07:48 -05:00
Gergő Mihály 390cff0604 Translated using Weblate (Hungarian)
Currently translated at 55.2% (225 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/hu/
2026-02-22 13:09:46 +00:00
binwiederhier 6375c2ce60 Renaming for consistency 2026-02-21 21:29:29 -05:00
binwiederhier 459c80ef9b Refactor tests again: forEachBackend 2026-02-21 21:02:41 -05:00
binwiederhier b1eb90addc Move NewPollRequestMessage 2026-02-21 18:01:36 -05:00
binwiederhier 4b6979aa89 Move model constants 2026-02-21 10:42:34 -05:00
binwiederhier c76e39bb0e Manual updates 2026-02-21 10:36:09 -05:00
binwiederhier b82e1c3915 Release notes 2026-02-21 10:02:27 -05:00
binwiederhier 07e60ba041 Merge branch 'main' into postgres-support 2026-02-21 09:59:55 -05:00
binwiederhier 4e22e7f4c8 Release notes 2026-02-21 09:58:52 -05:00
binwiederhier cf3ae187ce Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-02-21 09:56:33 -05:00
Philipp C. Heckel d19b825192 Merge pull request #1620 from uzkikh/fix/smtp-html-br-linebreaks
fix(smtp): preserve <br> line breaks in HTML emails
2026-02-21 09:56:24 -05:00
Ivan Uzkikh 2e499389fc fix(smtp): preserve <br> line breaks in HTML emails
HTML-only emails (e.g. from Synology DSM 7.3 Task Scheduler) use <br>
tags for line breaks. The existing implementation passed the raw HTML
body to bluemonday with AddSpaceWhenStrippingTag(true), which replaced
every tag including <br> with a space, causing all content to appear
on a single unreadable line.

Fix: convert <br> tags to newlines before stripping HTML, so line break
semantics are preserved in the notification body.

Resolves the gap noted in #690 ("very sub-par" HTML support).
2026-02-21 14:44:06 +01:00
binwiederhier 7c69a76345 Docs 2026-02-20 16:56:47 -05:00
binwiederhier a28d8e7924 Update docs, manual changes 2026-02-20 16:36:41 -05:00
binwiederhier 13a3062a7f Re-add comments 2026-02-20 16:15:07 -05:00
binwiederhier eb6e1ac44a Docs 2026-02-20 15:57:47 -05:00
binwiederhier a4c836b531 Refine 2026-02-20 15:36:12 -05:00
binwiederhier e818b063f7 Fmt 2026-02-20 08:25:32 -05:00
binwiederhier 039d555689 Fix tests 2026-02-20 08:19:25 -05:00
binwiederhier 209d5a4c62 Update parallelism 2026-02-19 22:42:08 -05:00
binwiederhier bf265449ac Docs 2026-02-19 22:38:27 -05:00
binwiederhier 4cbd80c68e Use one PG connection, add support for connection params 2026-02-19 22:34:53 -05:00
binwiederhier 305e3fc9af DB conn to 25 2026-02-19 21:36:09 -05:00
binwiederhier 9e4a48b058 Make server tests also run against postgres 2026-02-19 20:48:01 -05:00
Philipp C. Heckel 93cd7f99f8 Merge pull request #1618 from PanderMusubi/doc_zabbix
add zabbix-ntfy to intergration doc
2026-02-19 11:00:21 -05:00
PanderMusubi 28e85df36e add zabbix-ntfy to intergration doc 2026-02-19 15:47:49 +01:00
binwiederhier 939b3d1117 Fix lint, make pipeline use psotgres 2026-02-18 21:07:31 -05:00
binwiederhier 9cc9891f49 Add postgres to pipeline 2026-02-18 20:55:03 -05:00
binwiederhier 0d1f3444f2 fmt 2026-02-18 20:48:41 -05:00
binwiederhier 2716ede6e1 Extract message cache into message/ package with model/ types
Move message cache from server/message_cache.go into a dedicated
message/ package with Store interface, SQLite and PostgreSQL
implementations. Extract shared types into model/ package.
2026-02-18 20:22:44 -05:00
Philipp C. Heckel 2bc7b5217b Merge pull request #1615 from benkenawell/example/zsh-terminal
Add comment for zsh users in the terminal example
2026-02-18 20:12:49 -05:00
Benjamin Kenawell 046c0e8c79 Add comment for zsh users in the terminal example
Bash and Zsh terminals' history works slightly different. In bash, the
current command is in the history already. In zsh, the
history command does not have the currently running command in it.  But
it does set the HISTCMD variable to the entry number for the current
command. So we can load the current command with `history "$HISTCMD"` in
zsh and apply the same sed filter to get the same result as the bash
`history | tail -n1` command.

I think it makes more sense to leave this as a comment in the current
example than to create a new example, since it otherwise works the exact
same.  I've added this to my workflow locally!
2026-02-18 08:07:08 -05:00
Yaron Shahrabani 652b2097ad Translated using Weblate (Hebrew)
Currently translated at 17.4% (71 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/he/
2026-02-18 13:09:47 +01:00
Hunter Kehoe fb89b87efb do not escape JSON response 2025-12-07 14:15:36 -07:00
282 changed files with 41564 additions and 16270 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:
- "*"
+9 -6
View File
@@ -1,19 +1,22 @@
name: build
on: [ push, pull_request ]
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.24.x'
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Install dependencies
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@v3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
name: Checkout docs pages code
uses: actions/checkout@v3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io
+23 -6
View File
@@ -6,21 +6,38 @@ on:
jobs:
release:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: ntfy
POSTGRES_PASSWORD: ntfy
POSTGRES_DB: ntfy_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U ntfy"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable"
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.24.x'
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Docker login
uses: docker/login-action@v2
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
+27 -9
View File
@@ -1,19 +1,39 @@
name: test
on: [ push, pull_request ]
on:
push:
branches: [ main ]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: ntfy
POSTGRES_PASSWORD: ntfy
POSTGRES_DB: ntfy_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U ntfy"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable"
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.24.x'
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Install dependencies
@@ -23,8 +43,6 @@ jobs:
- name: Build web app (required for tests)
run: make web
- name: Run tests, formatting, vetting and linting
run: make check
run: make checkv
- name: Run coverage
run: make coverage
- name: Upload coverage to codecov.io
run: make coverage-upload
+3
View File
@@ -7,6 +7,9 @@ build/
server/docs/
server/site/
tools/fbsend/fbsend
tools/pgimport/pgimport
tools/loadtest/loadtest
tools/s3cli/s3cli
playground/
secrets/
*.iml
+1
View File
@@ -0,0 +1 @@
1.27.0
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier),
or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly
or email (contact@mail.ntfy.sh). All complaints will be reviewed and investigated promptly
and fairly.
All community leaders are obligated to respect the privacy and security of the
+15 -5
View File
@@ -1,8 +1,8 @@
FROM golang:1.24-bullseye as builder
FROM golang:1.25-bookworm AS builder
ARG VERSION=dev
ARG COMMIT=unknown
ARG NODE_MAJOR=18
ARG NODE_MAJOR=24
RUN apt-get update && apt-get install -y \
build-essential ca-certificates curl gnupg \
@@ -21,14 +21,14 @@ ADD Makefile .
# docs
ADD ./requirements.txt .
RUN make docs-deps
RUN --mount=type=cache,target=/root/.cache/pip make docs-deps
ADD ./mkdocs.yml .
ADD ./docs ./docs
RUN make docs-build
# web
ADD ./web/package.json ./web/package-lock.json ./web/
RUN make web-deps
RUN --mount=type=cache,target=/root/.npm make web-deps
ADD ./web ./web
RUN make web-build
@@ -41,7 +41,17 @@ ADD ./server ./server
ADD ./user ./user
ADD ./util ./util
ADD ./payments ./payments
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
ADD ./db ./db
ADD ./message ./message
ADD ./model ./model
ADD ./webpush ./webpush
ADD ./attachment ./attachment
ADD ./mail ./mail
ADD ./s3 ./s3
ADD ./action ./action
ADD ./template/gotext ./template/gotext
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
+96 -20
View File
@@ -5,7 +5,9 @@ PIP := pip3
VERSION := $(shell git describe --tag)
COMMIT := $(shell git rev-parse --short HEAD)
.PHONY:
# FORCE is an always-out-of-date target with no recipe; listing it as a prerequisite
# forces that target's recipe to run every time (the classic "FORCE target" idiom).
FORCE:
help:
@echo "Typical commands (more see below):"
@@ -43,6 +45,7 @@ help:
@echo " make web-deps - Install web app dependencies (npm install the universe)"
@echo " make web-build - Actually build the web app"
@echo " make web-lint - Run eslint on the web app"
@echo " make web-test - Run vitest unit tests for the web app"
@echo " make web-fmt - Run prettier on the web app"
@echo " make web-fmt-check - Run prettier on the web app, but don't change anything"
@echo
@@ -52,7 +55,9 @@ help:
@echo " make docs-build - Actually build the documentation"
@echo
@echo "Test/check:"
@echo " make test - Run tests"
@echo " make test - Run all tests (Go + web)"
@echo " make cli-test - Run Go tests only"
@echo " make web-test - Run web app tests only"
@echo " make race - Run tests with -race flag"
@echo " make coverage - Run tests and show coverage"
@echo " make coverage-html - Run tests and show coverage (as HTML)"
@@ -82,12 +87,12 @@ help:
# Building everything
clean: .PHONY
clean: FORCE
rm -rf dist build server/docs server/site
build: web docs cli
update: web-deps-update cli-deps-update docs-deps-update
update: web-deps-update cli-deps-update docs-deps-update go-check
docker pull alpine
docker-dev:
@@ -119,7 +124,7 @@ build-deps-ubuntu:
docs: docs-deps docs-build
docs-venv: .PHONY
docs-venv: FORCE
$(PYTHON) -m venv ./venv
docs-build: docs-venv
@@ -128,7 +133,7 @@ docs-build: docs-venv
docs-deps: docs-venv
(. venv/bin/activate && $(PIP) install -r requirements.txt)
docs-deps-update: .PHONY
docs-deps-update: FORCE
(. venv/bin/activate && $(PIP) install -r requirements.txt --upgrade)
@@ -146,11 +151,13 @@ web-build:
../server/site/config.js
web-deps:
cd web && $(NPM) install
cd web && $(NPM) ci
# Use "npm ci" so that we don't change the package lock file
# 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:
cd web && $(NPM) run format
@@ -161,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
@@ -263,25 +273,31 @@ cli-build-results:
# Test/check targets
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
check: test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
test: .PHONY
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
testv: .PHONY
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
test: cli-test web-test
race: .PHONY
go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
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)')
cli-testv: FORCE
go test -v $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
race: FORCE
go test -v -race $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
coverage:
mkdir -p build/coverage
go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools|web)')
go tool cover -func build/coverage/coverage.txt
coverage-html:
mkdir -p build/coverage
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list -f '{{if .TestGoFiles}}{{.ImportPath}}{{end}}' ./... | grep -vE 'ntfy/v2/(test|examples|tools)')
go tool cover -html build/coverage/coverage.txt
coverage-upload:
@@ -301,17 +317,73 @@ vet:
lint:
which golint || go install golang.org/x/lint/golint@latest
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
go list ./... | grep -v /vendor/ | grep -vE 'ntfy/v2/template/gotext' | xargs -L1 golint -set_exit_status
staticcheck: .PHONY
staticcheck: FORCE
rm -rf build/staticcheck
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
mkdir -p build/staticcheck
ln -s "go" build/staticcheck/go
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck $$(go list ./... | grep -vE 'ntfy/v2/template/gotext')
rm -rf build/staticcheck
# Vendored template targets (see template/README.md)
TEMPLATE_GO_VERSION := go$(shell cat .go-version 2>/dev/null)
update-template:
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo "ERROR: local Go $$(go env GOVERSION) != $(TEMPLATE_GO_VERSION) pinned in .go-version."; \
echo "Bump .go-version and install that toolchain first: go install golang.org/dl/$(TEMPLATE_GO_VERSION)@latest && $(TEMPLATE_GO_VERSION) download"; \
exit 1; \
fi
src="$$(go env GOROOT)/src"; \
rm -f template/gotext/*.go template/gotext/fmtsort/*.go; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" template/gotext/; done; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" template/gotext/fmtsort/; done; \
sed -i 's/^package template$$/package gotext/' template/gotext/*.go; \
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' template/gotext/*.go; \
( cd template/gotext && for p in patches/*.patch; do echo "Applying $$p"; git apply "$$p" || exit 1; done )
go env GOVERSION > template/gotext/GENERATED_FROM
@echo "Regenerated template/gotext/ from $(TEMPLATE_GO_VERSION) (files enumerated via 'go list'); review with 'git diff'."
template-check: FORCE
@if [ "$$(cat template/gotext/GENERATED_FROM)" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo "ERROR: template/gotext was generated from $$(cat template/gotext/GENERATED_FROM), but .go-version pins $(TEMPLATE_GO_VERSION). Run 'make update-template' on the pinned Go."; \
exit 1; \
fi
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo "SKIP: local Go $$(go env GOVERSION) != pinned $(TEMPLATE_GO_VERSION); skipping vendored template content check (version marker already verified)."; \
exit 0; \
fi
@tmp=$$(mktemp -d); src="$$(go env GOROOT)/src"; \
mkdir -p "$$tmp/gotext/fmtsort"; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" "$$tmp/gotext/"; done; \
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" "$$tmp/gotext/fmtsort/"; done; \
sed -i 's/^package template$$/package gotext/' "$$tmp/gotext/"*.go; \
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' "$$tmp/gotext/"*.go; \
cp template/gotext/patches/*.patch "$$tmp/"; \
( cd "$$tmp/gotext" && for p in "$$tmp"/*.patch; do git apply "$$p" || exit 1; done ); \
if diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext >/dev/null 2>&1; then \
rm -rf "$$tmp"; \
else \
echo "ERROR: template/gotext/ drifted from GOROOT+patches (or its file set changed). Run 'make update-template' on Go $(TEMPLATE_GO_VERSION):"; \
diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext; \
rm -rf "$$tmp"; exit 1; \
fi
# go-check is advisory only (never fails): it warns when the pinned Go (.go-version) is behind the
# latest upstream release, so template/gotext doesn't silently fall behind on text/template fixes.
go-check: FORCE
@latest=$$(curl -s --max-time 10 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); \
if [ -n "$$latest" ] && [ "$$latest" != "$(TEMPLATE_GO_VERSION)" ]; then \
echo ""; \
echo "note: latest Go is $$latest, but template/gotext is pinned to $(TEMPLATE_GO_VERSION) (.go-version)."; \
echo " to bump: install $$latest, set .go-version to $${latest#go}, then run 'make update-template'."; \
fi
# Releasing targets
release: clean cli-deps release-checks docs web check
@@ -322,6 +394,10 @@ release-snapshot: clean cli-deps docs web check
release-checks:
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
if [ "$$(go env GOVERSION)" != "go$$(cat .go-version)" ]; then\
echo "ERROR: releases must use the pinned Go toolchain (go$$(cat .go-version) from .go-version), but this is $$(go env GOVERSION). This also ensures 'make check' enforces (not skips) the template/gotext drift check.";\
exit 1;\
fi
if ! grep -q $(LATEST_TAG) docs/install.md; then\
echo "ERROR: Must update docs/install.md with latest tag first.";\
exit 1;\
+1 -2
View File
@@ -84,8 +84,6 @@ Thank you to our commercial sponsors, who help keep the service running and the
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></a>
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
@@ -268,6 +266,7 @@ Third-party libraries and resources:
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
* Go's [text/template](https://pkg.go.dev/text/template) (BSD-3-Clause) is vendored under [template/gotext/](template/gotext/) with a small patch adding an execution deadline (see [template/gotext/README.md](template/gotext/README.md))
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
+14 -11
View File
@@ -1,4 +1,6 @@
package server
// Package action parses the "action buttons" that can be attached to a notification, in both the
// JSON and the human-readable "simple" format described at https://ntfy.sh/docs/publish/#action-buttons.
package action
import (
"encoding/json"
@@ -8,6 +10,7 @@ import (
"strings"
"unicode/utf8"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
@@ -36,10 +39,10 @@ type actionParser struct {
pos int
}
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
// Parse parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
func parseActions(s string) (actions []*action, err error) {
func Parse(s string) (actions []*model.Action, err error) {
// Parse JSON or simple format
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[") {
@@ -80,8 +83,8 @@ func parseActions(s string) (actions []*action, err error) {
}
// parseActionsFromJSON converts a JSON array into an array of actions
func parseActionsFromJSON(s string) ([]*action, error) {
actions := make([]*action, 0)
func parseActionsFromJSON(s string) ([]*model.Action, error) {
actions := make([]*model.Action, 0)
if err := json.Unmarshal([]byte(s), &actions); err != nil {
return nil, fmt.Errorf("JSON error: %w", err)
}
@@ -107,7 +110,7 @@ func parseActionsFromJSON(s string) ([]*action, error) {
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
func parseActionsFromSimple(s string) ([]*action, error) {
func parseActionsFromSimple(s string) ([]*model.Action, error) {
if !utf8.ValidString(s) {
return nil, errors.New("invalid utf-8 string")
}
@@ -119,8 +122,8 @@ func parseActionsFromSimple(s string) ([]*action, error) {
}
// Parse loops trough parseAction() until the end of the string is reached
func (p *actionParser) Parse() ([]*action, error) {
actions := make([]*action, 0)
func (p *actionParser) Parse() ([]*model.Action, error) {
actions := make([]*model.Action, 0)
for !p.eof() {
a, err := p.parseAction()
if err != nil {
@@ -134,8 +137,8 @@ func (p *actionParser) Parse() ([]*action, error) {
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
// and then uses populateAction to interpret the keys/values. The function terminates
// when EOF or ";" is reached.
func (p *actionParser) parseAction() (*action, error) {
a := newAction()
func (p *actionParser) parseAction() (*model.Action, error) {
a := model.NewAction()
section := 0
for {
key, value, last, err := p.parseSection()
@@ -155,7 +158,7 @@ func (p *actionParser) parseAction() (*action, error) {
// populateAction is the "business logic" of the parser. It applies the key/value
// pair to the action instance.
func populateAction(newAction *action, section int, key, value string) error {
func populateAction(newAction *model.Action, section int, key, value string) error {
// Auto-expand keys based on their index
if key == "" && section == 0 {
key = "action"
@@ -1,4 +1,4 @@
package server
package action
import (
"testing"
@@ -7,12 +7,12 @@ import (
)
func TestParseActions(t *testing.T) {
actions, err := parseActions("[]")
actions, err := Parse("[]")
require.Nil(t, err)
require.Empty(t, actions)
// Basic test
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -23,7 +23,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "https://door.lan", actions[1].URL)
// JSON
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
actions, err = Parse(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -34,7 +34,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "https://door.lan", actions[1].URL)
// Other params
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
actions, err = Parse("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -44,7 +44,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "this is a body", actions[0].Body)
// Extras with underscores
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
actions, err = Parse("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
@@ -54,7 +54,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
// Broadcast action with intent
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
actions, err = Parse("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
@@ -62,7 +62,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
// Headers with dashes
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
actions, err = Parse("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -72,7 +72,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
// Quotes
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
actions, err = Parse(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -80,7 +80,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://example.com`, actions[0].URL)
// Single quotes
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
actions, err = Parse(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -88,7 +88,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://example.com`, actions[0].URL)
// Single quotes (JSON)
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
actions, err = Parse(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -97,7 +97,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
// Out of order
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
actions, err = Parse(`label="Out of order!" , action="http", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -105,7 +105,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://example.com`, actions[0].URL)
// Spaces
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
actions, err = Parse(`action = http, label = 'this is a label', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -113,7 +113,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://google.com`, actions[0].URL)
// Non-ASCII
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
actions, err = Parse(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -121,7 +121,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `http://google.com`, actions[0].URL)
// Multiple actions, awkward spacing
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
actions, err = Parse(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
@@ -134,7 +134,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, true, actions[1].Clear)
// Copy action (simple format)
actions, err = parseActions("copy, Copy code, 1234")
actions, err = Parse("copy, Copy code, 1234")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -142,7 +142,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "1234", actions[0].Value)
// Copy action (JSON)
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
actions, err = Parse(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -150,7 +150,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, "567890", actions[0].Value)
// Copy action with clear
actions, err = parseActions("copy, Copy code, 1234, clear=true")
actions, err = Parse("copy, Copy code, 1234, clear=true")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -159,7 +159,7 @@ func TestParseActions(t *testing.T) {
require.Equal(t, true, actions[0].Clear)
// Copy action with explicit value key
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
actions, err = Parse("action=copy, label=Copy token, clear=true, value=abc-123-def")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
@@ -168,56 +168,56 @@ func TestParseActions(t *testing.T) {
require.True(t, actions[0].Clear)
// Copy action without value (error)
_, err = parseActions("copy, Copy code")
_, err = Parse("copy, Copy code")
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
// Invalid syntax
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
_, err = Parse(`label="Out of order!" x, action="http", url=http://example.com`)
require.EqualError(t, err, "unexpected character 'x' at position 22")
_, err = parseActions(`label="", action="http", url=http://example.com`)
_, err = Parse(`label="", action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
_, err = parseActions(`label=, action="http", url=http://example.com`)
_, err = Parse(`label=, action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
_, err = Parse(`label="xx", action="http", url=http://example.com, what is this anyway`)
require.EqualError(t, err, "term 'what is this anyway' unknown")
_, err = parseActions(`fdsfdsf`)
_, err = Parse(`fdsfdsf`)
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
_, err = Parse(`aaa=a, "bbb, 'ccc, ddd, eee "`)
require.EqualError(t, err, "key 'aaa' unknown")
_, err = parseActions(`action=http, label="omg the end quote is missing`)
_, err = Parse(`action=http, label="omg the end quote is missing`)
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
_, err = parseActions(`;;;;`)
_, err = Parse(`;;;;`)
require.EqualError(t, err, "only 3 actions allowed")
_, err = parseActions(`,,,,,,;;`)
_, err = Parse(`,,,,,,;;`)
require.EqualError(t, err, "term '' unknown")
_, err = parseActions(`''";,;"`)
_, err = Parse(`''";,;"`)
require.EqualError(t, err, "unexpected character '\"' at position 2")
_, err = parseActions(`action=http, label=a label, body=somebody`)
_, err = Parse(`action=http, label=a label, body=somebody`)
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
_, err = Parse(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
_, err = parseActions(`[ invalid json ]`)
_, err = Parse(`[ invalid json ]`)
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
_, err = parseActions(`[ { "some": "object" } ]`)
_, err = Parse(`[ { "some": "object" } ]`)
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions("\x00\x01\xFFx\xFE")
_, err = Parse("\x00\x01\xFFx\xFE")
require.EqualError(t, err, "invalid utf-8 string")
_, err = parseActions(`http, label, http://x.org, clear=x`)
_, err = Parse(`http, label, http://x.org, clear=x`)
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+23
View File
@@ -0,0 +1,23 @@
package attachment
import (
"io"
"time"
)
// backendObject represents an object stored in a backend.
type object struct {
ID string
Size int64
LastModified time.Time
}
// backend is a minimal I/O interface for storing and retrieving attachment files.
// It has no knowledge of size tracking, limiting, or ID validation.
type backend interface {
Put(id string, reader io.Reader, untrustedLength int64) error
Get(id string) (io.ReadCloser, int64, error)
List() ([]object, error)
Delete(ids ...string) error
DeleteIncomplete(cutoff time.Time) error
}
+94
View File
@@ -0,0 +1,94 @@
package attachment
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type fileBackend struct {
dir string
}
var _ backend = (*fileBackend)(nil)
func newFileBackend(dir string) (*fileBackend, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
return &fileBackend{dir: dir}, nil
}
func (b *fileBackend) Put(id string, reader io.Reader, untrustedLength int64) error {
if untrustedLength > 0 {
reader = io.LimitReader(reader, untrustedLength)
}
file := filepath.Join(b.dir, id)
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer f.Close()
n, err := io.Copy(f, reader)
if err != nil {
os.Remove(file)
return err
} else if untrustedLength > 0 && n != untrustedLength {
os.Remove(file)
return fmt.Errorf("content length mismatch: claimed %d, got %d", untrustedLength, n)
}
if err := f.Close(); err != nil {
os.Remove(file)
return err
}
return nil
}
func (b *fileBackend) List() ([]object, error) {
entries, err := os.ReadDir(b.dir)
if err != nil {
return nil, err
}
objects := make([]object, 0, len(entries))
for _, e := range entries {
info, err := e.Info()
if err != nil {
return nil, err
}
objects = append(objects, object{
ID: e.Name(),
Size: info.Size(),
LastModified: info.ModTime(),
})
}
return objects, nil
}
func (b *fileBackend) Get(id string) (io.ReadCloser, int64, error) {
file := filepath.Join(b.dir, id)
stat, err := os.Stat(file)
if err != nil {
return nil, 0, err
}
f, err := os.Open(file)
if err != nil {
return nil, 0, err
}
return f, stat.Size(), nil
}
func (b *fileBackend) Delete(ids ...string) error {
for _, id := range ids {
file := filepath.Join(b.dir, id)
if err := os.Remove(file); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func (b *fileBackend) DeleteIncomplete(_ time.Time) error {
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package attachment
import (
"context"
"io"
"time"
"heckel.io/ntfy/v2/s3"
)
type s3Backend struct {
client *s3.Client
}
var _ backend = (*s3Backend)(nil)
func newS3Backend(client *s3.Client) *s3Backend {
return &s3Backend{client: client}
}
func (b *s3Backend) Put(id string, reader io.Reader, untrustedLength int64) error {
return b.client.PutObject(context.Background(), id, reader, untrustedLength)
}
func (b *s3Backend) Get(id string) (io.ReadCloser, int64, error) {
return b.client.GetObject(context.Background(), id)
}
func (b *s3Backend) List() ([]object, error) {
objects, err := b.client.ListObjectsV2(context.Background())
if err != nil {
return nil, err
}
result := make([]object, 0, len(objects))
for _, obj := range objects {
result = append(result, object{
ID: obj.Key,
Size: obj.Size,
LastModified: obj.LastModified,
})
}
return result, nil
}
func (b *s3Backend) Delete(ids ...string) error {
return b.client.DeleteObjects(context.Background(), ids)
}
func (b *s3Backend) DeleteIncomplete(cutoff time.Time) error {
return b.client.AbortIncompleteUploads(context.Background(), cutoff)
}
+246
View File
@@ -0,0 +1,246 @@
package attachment
import (
"errors"
"fmt"
"io"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/s3"
"heckel.io/ntfy/v2/util"
)
const (
tagStore = "attachment_store"
syncInterval = 15 * time.Minute // How often to run the background sync loop
)
var errInvalidFileID = errors.New("invalid file ID")
// Store manages attachment storage with shared logic for size tracking, limiting,
// ID validation, and background sync to reconcile storage with the database.
type Store struct {
backend backend
limit int64 // Defined limit of the store in bytes
size int64 // Current size of the store in bytes
sizes map[string]int64 // File ID -> size, for subtracting on Remove
attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments
orphanGracePeriod time.Duration // Don't delete orphaned objects younger than this
closeChan chan struct{}
doneChan chan struct{}
mu sync.RWMutex // Protects size and sizes
}
// NewFileStore creates a new file-system backed attachment cache
func NewFileStore(dir string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
b, err := newFileBackend(dir)
if err != nil {
return nil, err
}
return newStore(b, totalSizeLimit, orphanGracePeriod, attachmentsWithSizes)
}
// NewS3Store creates a new S3-backed attachment cache. The s3URL must be in the format:
//
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
func NewS3Store(s3URL string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
config, err := s3.ParseURL(s3URL)
if err != nil {
return nil, err
}
return newStore(newS3Backend(s3.New(config)), totalSizeLimit, orphanGracePeriod, attachmentsWithSizes)
}
func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
c := &Store{
backend: backend,
limit: totalSizeLimit,
sizes: make(map[string]int64),
attachmentsWithSizes: attachmentsWithSizes,
orphanGracePeriod: orphanGracePeriod,
closeChan: make(chan struct{}),
doneChan: make(chan struct{}),
}
// Hydrate sizes from the database immediately so that Size()/Remaining()/Remove()
// are accurate from the start, without waiting for the first sync() call.
if attachmentsWithSizes != nil {
attachments, err := attachmentsWithSizes()
if err != nil {
return nil, fmt.Errorf("attachment store: failed to load existing attachments: %w", err)
}
for id, size := range attachments {
c.sizes[id] = size
c.size += size
}
go c.syncLoop()
} else {
close(c.doneChan)
}
return c, nil
}
// Write stores an attachment file. The id is validated, and the write is subject to
// the total size limit and any additional limiters. The untrustedLength is a hint
// from the client's Content-Length header; backends may use it to optimize uploads (e.g.
// streaming directly to S3 without buffering).
func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limiters ...util.Limiter) (int64, error) {
if !model.ValidMessageID(id) {
return 0, errInvalidFileID
}
log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment")
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()))
countingReader := util.NewCountingReader(reader)
limitReader := util.NewLimitReader(countingReader, limiters...)
if err := c.backend.Put(id, limitReader, untrustedLength); err != nil {
c.backend.Delete(id) //nolint:errcheck
return 0, err
}
size := countingReader.Total()
c.mu.Lock()
c.size += size
c.sizes[id] = size
c.mu.Unlock()
return size, nil
}
// Read retrieves an attachment file by ID
func (c *Store) Read(id string) (io.ReadCloser, int64, error) {
if !model.ValidMessageID(id) {
return nil, 0, errInvalidFileID
}
return c.backend.Get(id)
}
// Remove deletes attachment files by ID and subtracts their known sizes from
// the total. Sizes for objects not tracked (e.g. written before this process
// started and before the first sync) are corrected by the next sync() call.
func (c *Store) Remove(ids ...string) error {
for _, id := range ids {
if !model.ValidMessageID(id) {
return errInvalidFileID
}
}
// Remove from backend
for _, id := range ids {
log.Tag(tagStore).Field("message_id", id).Debug("Removing attachment")
}
if err := c.backend.Delete(ids...); err != nil {
return err
}
// Update total cache size
c.mu.Lock()
for _, id := range ids {
if size, ok := c.sizes[id]; ok {
c.size -= size
delete(c.sizes, id)
}
}
if c.size < 0 {
c.size = 0
}
c.mu.Unlock()
return nil
}
// Sync triggers an immediate reconciliation of storage with the database.
func (c *Store) Sync() error {
return c.sync()
}
// sync reconciles the backend storage with the database. It lists all objects,
// deletes orphans (not in the valid ID set and older than the grace period), and
// recomputes the total size from the existing attachments in the database.
func (c *Store) sync() error {
if c.attachmentsWithSizes == nil {
return nil
}
attachmentsWithSizes, err := c.attachmentsWithSizes()
if err != nil {
return fmt.Errorf("attachment sync: failed to get existing attachments: %w", err)
}
remoteObjects, err := c.backend.List()
if err != nil {
return fmt.Errorf("attachment sync: failed to list objects: %w", err)
}
// Calculate total cache size and collect orphaned attachments, excluding objects younger
// than the grace period to account for races, and skipping objects with invalid IDs.
cutoff := time.Now().Add(-c.orphanGracePeriod)
var orphanIDs []string
var count, totalSize int64
sizes := make(map[string]int64, len(remoteObjects))
for _, obj := range remoteObjects {
if !model.ValidMessageID(obj.ID) {
continue
}
if _, ok := attachmentsWithSizes[obj.ID]; !ok && obj.LastModified.Before(cutoff) {
orphanIDs = append(orphanIDs, obj.ID)
} else {
count++
totalSize += attachmentsWithSizes[obj.ID]
sizes[obj.ID] = attachmentsWithSizes[obj.ID]
}
}
log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", count, util.FormatSizeHuman(totalSize))
c.mu.Lock()
c.size = totalSize
c.sizes = sizes
c.mu.Unlock()
// Delete orphaned attachments
if len(orphanIDs) > 0 {
log.Tag(tagStore).Debug("Deleting %d orphaned attachment(s)", len(orphanIDs))
if err := c.backend.Delete(orphanIDs...); err != nil {
return fmt.Errorf("attachment sync: failed to delete orphaned objects: %w", err)
}
}
// Clean up incomplete uploads (S3 only)
if err := c.backend.DeleteIncomplete(cutoff); err != nil {
log.Tag(tagStore).Err(err).Warn("Failed to abort incomplete uploads from attachment cache")
}
return nil
}
// Size returns the current total size of all attachments
func (c *Store) Size() int64 {
c.mu.RLock()
defer c.mu.RUnlock()
return c.size
}
// Remaining returns the remaining capacity for attachments
func (c *Store) Remaining() int64 {
c.mu.RLock()
defer c.mu.RUnlock()
remaining := c.limit - c.size
if remaining < 0 {
return 0
}
return remaining
}
// Close stops the background sync goroutine and waits for it to finish
func (c *Store) Close() {
close(c.closeChan)
<-c.doneChan
}
func (c *Store) syncLoop() {
defer close(c.doneChan)
if err := c.sync(); err != nil {
log.Tag(tagStore).Err(err).Warn("Attachment sync failed")
}
ticker := time.NewTicker(syncInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := c.sync(); err != nil {
log.Tag(tagStore).Err(err).Warn("Attachment sync failed")
}
case <-c.closeChan:
return
}
}
}
+17
View File
@@ -0,0 +1,17 @@
package attachment
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) {
t.Helper()
dir = t.TempDir()
cache, err := NewFileStore(dir, totalSizeLimit, time.Hour, nil)
require.Nil(t, err)
t.Cleanup(func() { cache.Close() })
return dir, cache
}
+120
View File
@@ -0,0 +1,120 @@
package attachment
import (
"context"
"io"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/s3"
)
func TestS3Store_WriteWithPrefix(t *testing.T) {
s3URL := os.Getenv("NTFY_TEST_S3_URL")
if s3URL == "" {
t.Skip("NTFY_TEST_S3_URL not set")
}
cfg, err := s3.ParseURL(s3URL)
require.Nil(t, err)
cfg.Prefix = "test-prefix"
client := s3.New(cfg)
deleteAllObjects(t, client)
backend := newS3Backend(client)
cache, err := newStore(backend, 10*1024, time.Hour, nil)
require.Nil(t, err)
t.Cleanup(func() {
deleteAllObjects(t, client)
cache.Close()
})
size, err := cache.Write("abcdefghijkl", strings.NewReader("test"), 0)
require.Nil(t, err)
require.Equal(t, int64(4), size)
reader, _, err := cache.Read("abcdefghijkl")
require.Nil(t, err)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "test", string(data))
}
// --- Helpers ---
func newTestRealS3Store(t *testing.T, totalSizeLimit int64) (*Store, *modTimeOverrideBackend) {
t.Helper()
s3URL := os.Getenv("NTFY_TEST_S3_URL")
if s3URL == "" {
t.Skip("NTFY_TEST_S3_URL not set")
}
cfg, err := s3.ParseURL(s3URL)
require.Nil(t, err)
if cfg.Prefix != "" {
cfg.Prefix = cfg.Prefix + "/testpkg-attachment"
} else {
cfg.Prefix = "testpkg-attachment"
}
client := s3.New(cfg)
inner := newS3Backend(client)
wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)}
deleteAllObjects(t, client)
store, err := newStore(wrapper, totalSizeLimit, time.Hour, nil)
require.Nil(t, err)
t.Cleanup(func() {
deleteAllObjects(t, client)
store.Close()
})
return store, wrapper
}
func deleteAllObjects(t *testing.T, client *s3.Client) {
t.Helper()
for i := 0; i < 20; i++ {
objects, err := client.ListObjectsV2(context.Background())
require.Nil(t, err)
if len(objects) == 0 {
return
}
keys := make([]string, len(objects))
for j, obj := range objects {
keys[j] = obj.Key
}
require.Nil(t, client.DeleteObjects(context.Background(), keys))
time.Sleep(200 * time.Millisecond)
}
t.Fatal("timed out waiting for bucket to be empty")
}
// modTimeOverrideBackend wraps a backend and allows overriding LastModified times returned by List().
// This is used in tests to simulate old objects on backends (like real S3) where
// LastModified cannot be set directly.
type modTimeOverrideBackend struct {
backend
mu sync.Mutex
modTimes map[string]time.Time // object ID -> override time
}
func (b *modTimeOverrideBackend) List() ([]object, error) {
objects, err := b.backend.List()
if err != nil {
return nil, err
}
b.mu.Lock()
defer b.mu.Unlock()
for i, obj := range objects {
if t, ok := b.modTimes[obj.ID]; ok {
objects[i].LastModified = t
}
}
return objects, nil
}
func (b *modTimeOverrideBackend) setModTime(id string, t time.Time) {
b.mu.Lock()
b.modTimes[id] = t
b.mu.Unlock()
}
+352
View File
@@ -0,0 +1,352 @@
package attachment
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/util"
)
const testSizeLimit = 10 * 1024
func TestStore_WriteReadRemove(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Write
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 0)
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, int64(11), s.Size())
// Read back
reader, readSize, err := s.Read("abcdefghijkl")
require.Nil(t, err)
require.Equal(t, int64(11), readSize)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello world", string(data))
// Remove
require.Nil(t, s.Remove("abcdefghijkl"))
require.Equal(t, int64(0), s.Size())
// Read after remove should fail
_, _, err = s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_WriteRemoveMultiple(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
for i := 0; i < 5; i++ {
_, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0)
require.Nil(t, err)
}
require.Equal(t, int64(500), s.Size())
require.Nil(t, s.Remove("abcdefghijk1", "abcdefghijk3"))
require.Equal(t, int64(300), s.Size())
// Removed files should not be readable
_, _, err := s.Read("abcdefghijk1")
require.Error(t, err)
_, _, err = s.Read("abcdefghijk3")
require.Error(t, err)
// Remaining files should still be readable
for _, id := range []string{"abcdefghijk0", "abcdefghijk2", "abcdefghijk4"} {
reader, _, err := s.Read(id)
require.Nil(t, err)
reader.Close()
}
})
}
func TestStore_WriteTotalSizeLimit(t *testing.T) {
forEachBackend(t, 100, func(t *testing.T, s *Store, _ func(string)) {
// First write fits
_, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0)
require.Nil(t, err)
require.Equal(t, int64(80), s.Size())
require.Equal(t, int64(20), s.Remaining())
// Second write exceeds total limit
_, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0)
require.ErrorIs(t, err, util.ErrLimitReached)
})
}
func TestStore_WriteAdditionalLimiter(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
_, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100))
require.ErrorIs(t, err, util.ErrLimitReached)
// File should not be readable (was cleaned up)
_, _, err = s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_WriteWithLimiter(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
size, err := s.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999))
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, int64(11), s.Size())
})
}
func TestStore_WriteOverwriteSameID(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Write 100 bytes
_, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 100)), 0)
require.Nil(t, err)
require.Equal(t, int64(100), s.Size())
// Overwrite with 50 bytes
_, err = s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 50)), 0)
require.Nil(t, err)
require.Equal(t, int64(150), s.Size()) // Store tracks both writes
// Read back should return the latest content
reader, readSize, err := s.Read("abcdefghijkl")
require.Nil(t, err)
require.Equal(t, int64(50), readSize)
reader.Close()
})
}
func TestStore_WriteAfterFailure(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Failed write: limiter rejects it
_, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100))
require.ErrorIs(t, err, util.ErrLimitReached)
require.Equal(t, int64(0), s.Size())
// Subsequent write with a different ID should succeed
size, err := s.Write("abcdefghijk2", strings.NewReader("hello"), 0)
require.Nil(t, err)
require.Equal(t, int64(5), size)
require.Equal(t, int64(5), s.Size())
// The failed ID should not be readable
_, _, err = s.Read("abcdefghijkl")
require.Error(t, err)
// The successful ID should be readable
reader, _, err := s.Read("abcdefghijk2")
require.Nil(t, err)
reader.Close()
})
}
func TestStore_SyncRecomputesSize(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) {
// Write two files
_, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 100)), 0)
require.Nil(t, err)
_, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 200)), 0)
require.Nil(t, err)
require.Equal(t, int64(300), s.Size())
// Corrupt the in-memory size tracking
s.mu.Lock()
s.size = 999
s.mu.Unlock()
require.Equal(t, int64(999), s.Size())
// Set attachmentsWithSizes to include both files so nothing gets deleted
s.attachmentsWithSizes = func() (map[string]int64, error) {
return map[string]int64{"abcdefghijk0": 100, "abcdefghijk1": 200}, nil
}
// Sync should recompute size from the backend
require.Nil(t, s.sync())
require.Equal(t, int64(300), s.Size())
})
}
func TestStore_ReadNotFound(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
_, _, err := s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_InvalidID(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
_, err := s.Write("bad", strings.NewReader("x"), 0)
require.Equal(t, errInvalidFileID, err)
_, _, err = s.Read("bad")
require.Equal(t, errInvalidFileID, err)
err = s.Remove("bad")
require.Equal(t, errInvalidFileID, err)
})
}
func TestStore_WriteLargeObjects(t *testing.T) {
sizes := map[string]int64{
"100B": 100,
"6MB": 6 * 1024 * 1024,
"12MB": 12 * 1024 * 1024,
}
for name, sz := range sizes {
t.Run(name, func(t *testing.T) {
forEachBackend(t, sz+1024, func(t *testing.T, s *Store, _ func(string)) {
data := make([]byte, sz)
for i := range data {
data[i] = byte(i % 251)
}
size, err := s.Write("abcdefghijkl", bytes.NewReader(data), 0)
require.Nil(t, err)
require.Equal(t, sz, size)
require.Equal(t, sz, s.Size())
reader, readSize, err := s.Read("abcdefghijkl")
require.Nil(t, err)
require.Equal(t, sz, readSize)
got, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, data, got)
})
})
}
}
func TestStore_WriteUntrustedLengthExact(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 11)
require.Nil(t, err)
require.Equal(t, int64(11), size)
reader, _, err := s.Read("abcdefghijkl")
require.Nil(t, err)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello world", string(data))
})
}
func TestStore_WriteUntrustedLengthBodyLonger(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored
size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 5)
require.Nil(t, err)
require.Equal(t, int64(5), size)
reader, _, err := s.Read("abcdefghijkl")
require.Nil(t, err)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello", string(data))
})
}
func TestStore_WriteUntrustedLengthBodyShorter(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Body has 5 bytes, but we claim 100 — should fail
_, err := s.Write("abcdefghijkl", strings.NewReader("hello"), 100)
require.Error(t, err)
// File should not be readable (was cleaned up)
_, _, err = s.Read("abcdefghijkl")
require.Error(t, err)
})
}
func TestStore_Sync(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) {
// Write some files
_, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
_, err = s.Write("abcdefghijk1", strings.NewReader("file1"), 0)
require.Nil(t, err)
_, err = s.Write("abcdefghijk2", strings.NewReader("file2"), 0)
require.Nil(t, err)
require.Equal(t, int64(15), s.Size())
// Set the ID provider to only know about file 0 and 2
s.attachmentsWithSizes = func() (map[string]int64, error) {
return map[string]int64{"abcdefghijk0": 5, "abcdefghijk2": 5}, nil
}
// Make file 1 old enough to be cleaned up
makeOld("abcdefghijk1")
// Run sync
require.Nil(t, s.sync())
// File 1 should be deleted (orphan, old enough)
_, _, err = s.Read("abcdefghijk1")
require.Error(t, err)
// Files 0 and 2 should still be readable
r, _, err := s.Read("abcdefghijk0")
require.Nil(t, err)
r.Close()
r, _, err = s.Read("abcdefghijk2")
require.Nil(t, err)
r.Close()
// Size should be updated
require.Equal(t, int64(10), s.Size())
})
}
func TestStore_Sync_SkipsRecentFiles(t *testing.T) {
forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) {
// Write a file
_, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0)
require.Nil(t, err)
// Set the ID provider to return empty (no valid IDs)
s.attachmentsWithSizes = func() (map[string]int64, error) {
return map[string]int64{}, nil
}
// File was just created, so it should NOT be deleted (< 1 hour old)
require.Nil(t, s.sync())
// File should still exist
reader, _, err := s.Read("abcdefghijk0")
require.Nil(t, err)
reader.Close()
})
}
// forEachBackend runs f against both the file and S3 backends. It also provides a makeOld
// callback that makes a specific object's timestamp old enough for orphan cleanup (> 1 hour).
// For the file backend, this uses os.Chtimes; for the S3 backend, it overrides the object's
// LastModified time via a modTimeOverrideBackend wrapper. Objects start with recent timestamps
// by default. The S3 subtest is skipped if NTFY_TEST_S3_URL is not set.
func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s *Store, makeOld func(string))) {
t.Run("file", func(t *testing.T) {
dir, s := newTestFileStore(t, totalSizeLimit)
makeOld := func(id string) {
oldTime := time.Unix(1, 0)
os.Chtimes(filepath.Join(dir, id), oldTime, oldTime)
}
f(t, s, makeOld)
})
t.Run("s3", func(t *testing.T) {
s, wrapper := newTestRealS3Store(t, totalSizeLimit)
makeOld := func(id string) {
wrapper.setModTime(id, time.Unix(1, 0))
}
f(t, s, makeOld)
})
}
+185
View File
@@ -0,0 +1,185 @@
// Package ban implements the abuse ban-feed: it tracks per-prefix weighted "strikes" from rejected
// requests and appends breaching prefixes to a ban file that fail2ban tails. Keying by prefix (not
// by visitor) makes the accounting match the unit fail2ban bans, even for shared account visitors.
package ban
import (
"fmt"
"net/netip"
"os"
"sync"
"time"
"golang.org/x/time/rate"
"heckel.io/ntfy/v2/log"
)
const (
tag = "ban"
pruneInterval = 10 * time.Minute
writeInterval = 3 * time.Second
)
// Config is the Service's config, kept separate from server.Config to avoid an import cycle.
type Config struct {
File string // Ban file that fail2ban tails (must be non-empty; the caller decides whether the feature is enabled)
Window time.Duration // Rolling window over which weighted strikes are counted
Threshold int // Weighted strikes per Window before a prefix is banned
Weights Weights // Code matcher -> strike weight (0 = exempt)
PrefixBitsIPv4 int // Mask width for the ban unit, e.g. 32 (matches rate-limiting granularity)
PrefixBitsIPv6 int // Mask width for the ban unit, e.g. 64
}
// tracker is the per-prefix strike state: a weighted breach detector plus timestamps for pruning and throttling.
type tracker struct {
limiter *rate.Limiter
seen time.Time // Last strike, for pruning idle prefixes
emitted time.Time // Last ban-line write for this prefix, throttles re-emits to once per Window
}
// Service owns the ban-feed: per-prefix strike accounting, buffered file writes, and idle-prefix
// pruning. The caller owns the enable/disable decision -- only construct a Service when the feature
// is on (see server.New, which builds one only when a ban file is configured).
type Service struct {
conf *Config
mu sync.Mutex // Guards trackers and pending
trackers map[netip.Prefix]*tracker
pending []string // Formatted ban lines buffered by Record, flushed to the ban file by runWriteLoop
writeDone chan struct{} // Closed when runWriteLoop exits after its final flush
closeChan chan struct{}
closeOnce sync.Once
}
// NewService builds a Service and starts its background loops. The caller must only call it when the
// feature is enabled (conf non-nil, File non-empty); the Service does not model a disabled state.
func NewService(conf *Config) *Service {
s := &Service{
conf: conf,
trackers: make(map[netip.Prefix]*tracker),
closeChan: make(chan struct{}),
writeDone: make(chan struct{}),
}
go s.runPruneLoop()
go s.runWriteLoop()
return s
}
// Record counts one rejection against the IP's prefix bucket and, on breach, buffers a ban line
// (throttled to once per Window per prefix). No-ops for a non-4xx/5xx status or a zero-weight code.
func (s *Service) Record(ip netip.Addr, httpCode, errorCode int) {
if httpCode < 400 {
return
}
weight := s.conf.Weights.WeightFor(errorCode)
if weight == 0 {
return // Weight 0: exempt, no strike
}
prefix := s.prefix(ip)
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
t := s.trackers[prefix]
if t == nil {
t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(s.conf.Threshold)/s.conf.Window.Seconds()), s.conf.Threshold)}
s.trackers[prefix] = t
}
t.seen = now
if t.limiter.AllowN(now, weight) {
return // Within the strike budget, no breach
}
if !t.emitted.IsZero() && now.Sub(t.emitted) < s.conf.Window {
return // Already emitted this prefix within the window (one ban line per prefix per window)
}
t.emitted = now
s.pending = append(s.pending, formatBanLine(now, ip, prefix, httpCode, errorCode))
}
// prefix masks ip to the ban unit (PrefixBitsIPv4/IPv6) -- what fail2ban bans, e.g. a whole /64.
func (s *Service) prefix(ip netip.Addr) netip.Prefix {
if ip.Is4() {
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv4).Masked()
}
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv6).Masked()
}
// formatBanLine builds the "<RFC3339-UTC> <ip> <prefix> <http> <ntfy>" line the fail2ban filter
// parses. The timestamp is captured at breach time, not flush time.
func formatBanLine(t time.Time, ip netip.Addr, prefix netip.Prefix, httpCode, errorCode int) string {
return fmt.Sprintf("%s %s %s %d %d\n", t.UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, errorCode)
}
// runPruneLoop prunes idle prefixes until Close.
func (s *Service) runPruneLoop() {
ticker := time.NewTicker(pruneInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.prune()
case <-s.closeChan:
return
}
}
}
// runWriteLoop flushes buffered ban lines every writeInterval, plus a final flush on Close.
func (s *Service) runWriteLoop() {
defer close(s.writeDone)
ticker := time.NewTicker(writeInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.flush()
case <-s.closeChan:
s.flush()
return
}
}
}
// flush appends the buffered lines to the file in one open/write. Best-effort: a batch is dropped on
// error. Concurrent calls are safe -- pending is drained under mu, so only one flush writes a batch.
func (s *Service) flush() {
s.mu.Lock()
lines := s.pending
s.pending = nil
s.mu.Unlock()
if len(lines) == 0 {
return
}
f, err := os.OpenFile(s.conf.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Tag(tag).Err(err).Warn("Cannot open ban file %s, dropped %d ban(s)", s.conf.File, len(lines))
return
}
defer f.Close()
for i, line := range lines {
if _, err := f.WriteString(line); err != nil {
log.Tag(tag).Err(err).Warn("Cannot write to ban file %s, dropped %d ban(s)", s.conf.File, len(lines)-i)
return
}
}
}
// prune drops prefixes idle for a full Window -- their bucket has refilled, so forgetting them is a
// no-op that bounds memory under a flood of distinct IPs.
func (s *Service) prune() {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for prefix, t := range s.trackers {
if now.Sub(t.seen) >= s.conf.Window {
delete(s.trackers, prefix)
}
}
}
// Close stops the loops and blocks until the final flush completes. Idempotent.
func (s *Service) Close() {
s.closeOnce.Do(func() {
close(s.closeChan)
<-s.writeDone
})
}
+253
View File
@@ -0,0 +1,253 @@
package ban
import (
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var testIP = netip.MustParseAddr("1.2.3.4")
// newTestService creates a Service wired for testing, with the given ban file, weighted-bucket
// threshold, and weights, plus a 1-minute window (so the emit throttle only fires once per test).
func newTestService(t *testing.T, banFile string, threshold int, weights map[string]int) *Service {
t.Helper()
s := NewService(&Config{
File: banFile,
Window: time.Minute,
Threshold: threshold,
Weights: weights,
PrefixBitsIPv4: 32,
PrefixBitsIPv6: 64,
})
t.Cleanup(s.Close)
return s
}
// flushAndRead forces a synchronous flush of the buffered bans (writes are otherwise async, on the
// runWriteLoop ticker) and returns the ban file's lines.
func flushAndRead(t *testing.T, s *Service, path string) []string {
t.Helper()
s.flush()
data, err := os.ReadFile(path)
require.NoError(t, err)
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
}
func TestService_Record_Weight2BansAtHalfThreshold(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
s := newTestService(t, banFile, 10, map[string]int{"*": 2})
for i := 0; i < 5; i++ {
s.Record(testIP, 400, 40001)
}
s.flush()
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
s.Record(testIP, 400, 40001) // 6th hit cannot be covered -> breach
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
}
func TestService_Record_Weight10BansFast(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
s := newTestService(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
s.Record(testIP, 429, 42909)
s.flush()
require.NoFileExists(t, banFile)
s.Record(testIP, 429, 42909) // 2nd hit cannot be covered -> breach
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
}
func TestService_Record_Weight0NeverBans(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
s := newTestService(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
for i := 0; i < 100; i++ {
s.Record(testIP, 429, 42908)
}
s.flush()
require.NoFileExists(t, banFile)
}
func TestService_Record_SingleBucketNoRelaxation(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// One shared bucket per prefix: different codes draw down the SAME budget, so mixing them creates
// no extra headroom (unlike per-code buckets, which would relax the effective limit for a mixed
// offender).
s := newTestService(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
s.Record(testIP, 403, 40301) // weight 2 -> 8 left
s.Record(testIP, 403, 40301) // weight 2 -> 6 left
s.Record(testIP, 403, 40301) // weight 2 -> 4 left
s.flush()
require.NoFileExists(t, banFile)
for i := 0; i < 4; i++ {
s.Record(testIP, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
}
s.flush()
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
s.Record(testIP, 400, 40001) // one more cannot be covered -> breach
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
}
func TestService_Record_ExactLineFormat(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
before := time.Now().UTC().Truncate(time.Second)
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
}
after := time.Now().UTC()
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
parts := strings.Split(lines[0], " ")
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
ts, err := time.Parse(time.RFC3339, parts[0])
require.NoError(t, err)
require.Equal(t, time.UTC, ts.Location())
require.False(t, ts.Before(before))
require.False(t, ts.After(after.Add(time.Second)))
require.Equal(t, "1.2.3.4", parts[1]) // full IP
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
require.Equal(t, "429", parts[3]) // HTTP status
require.Equal(t, "42901", parts[4]) // ntfy code
}
func TestService_Record_IPv6MaskedToPrefix(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
ip := netip.MustParseAddr("2001:db8::abcd")
for i := 0; i < 3; i++ {
s.Record(ip, 429, 42901)
}
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
require.Len(t, parts, 5)
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
}
func TestService_Record_PerPrefixIsolation(t *testing.T) {
// Each source prefix gets its own bucket: one IP hammering to a breach must not push a different,
// quiet IP over the edge. This is the whole point of keying by prefix instead of by visitor.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 2, map[string]int{"*": 1})
noisy := netip.MustParseAddr("1.1.1.1")
quiet := netip.MustParseAddr("2.2.2.2")
for i := 0; i < 5; i++ {
s.Record(noisy, 429, 42901) // breaches its own bucket
}
s.Record(quiet, 429, 42901) // single hit, well under threshold
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1) // only the noisy prefix is written
require.True(t, strings.HasSuffix(lines[0], " 1.1.1.1 1.1.1.1/32 429 42901"))
}
func TestService_Record_OncePerWindowThrottle(t *testing.T) {
// Once a prefix has been written, further breaches within the window must not re-append it, so a
// persistent offender produces exactly one line per window (mirrors the old per-visitor banEmit).
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
for i := 0; i < 50; i++ {
s.Record(testIP, 429, 42901)
}
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
}
func TestService_Record_BansPassedIP(t *testing.T) {
// The Service bans the exact IP passed to Record -- the caller passes the offending request's IP,
// which for an account-keyed visitor is not the visitor's stored IP.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
offender := netip.MustParseAddr("5.6.7.8")
for i := 0; i < 3; i++ {
s.Record(offender, 429, 42901)
}
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
require.Equal(t, "5.6.7.8", parts[1]) // the IP passed to Record
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix
}
func TestService_Record_Ignores2xx3xx(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 3, map[string]int{"*": 1})
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
// legit high-volume publisher (lots of 200s) would get banned.
for i := 0; i < 20; i++ {
s.Record(testIP, 200, 20000)
s.Record(testIP, 302, 30000)
}
s.flush()
require.NoFileExists(t, banFile)
// A 4xx over the same budget still gets written.
for i := 0; i < 5; i++ {
s.Record(testIP, 400, 40001)
}
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
}
func TestService_Record_BuffersUntilFlush(t *testing.T) {
// Writes are async: a breach buffers the ban line rather than writing it synchronously on the
// request path. The line only reaches the file when runWriteLoop (or an explicit flush) runs.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
}
require.NoFileExists(t, banFile) // not written synchronously
s.mu.Lock()
require.Len(t, s.pending, 1) // one line buffered (throttled to once per window)
s.mu.Unlock()
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
}
func TestService_Close_FlushesPending(t *testing.T) {
// Close must flush buffered bans so nothing is lost on shutdown, and must block until it has.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := NewService(&Config{File: banFile, Window: time.Minute, Threshold: 1, Weights: Weights{"*": 1}, PrefixBitsIPv4: 32, PrefixBitsIPv6: 64})
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
}
require.NoFileExists(t, banFile) // still buffered
s.Close() // blocks until the final flush completes
data, err := os.ReadFile(banFile)
require.NoError(t, err)
require.Len(t, strings.Split(strings.TrimRight(string(data), "\n"), "\n"), 1)
}
func TestService_Prune_DropsIdlePrefixes(t *testing.T) {
// A prefix idle for a full window has a refilled bucket, so prune drops it to bound memory. An
// active prefix (seen within the window) is kept.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 10, map[string]int{"*": 1})
idle := netip.MustParseAddr("9.9.9.9")
s.Record(idle, 400, 40001)
s.Record(testIP, 400, 40001)
require.Len(t, s.trackers, 2)
// Backdate the idle prefix past the window, then prune.
idlePrefix := s.prefix(idle)
s.mu.Lock()
s.trackers[idlePrefix].seen = time.Now().Add(-2 * time.Minute)
s.mu.Unlock()
s.prune()
require.Len(t, s.trackers, 1)
_, ok := s.trackers[idlePrefix]
require.False(t, ok) // idle prefix dropped
_, ok = s.trackers[s.prefix(testIP)]
require.True(t, ok) // active prefix kept
}
+83
View File
@@ -0,0 +1,83 @@
package ban
import (
"fmt"
"strconv"
"strings"
)
// Weights maps a matcher key to a strike weight for the abuse ban-feed (see ParseWeights, WeightFor).
type Weights map[string]int
// ParseWeights normalizes a list like ["42909:10","403:2","*:1"] into a Weights map. A key is an
// exact ntfy code, a family ("429*"), a bare HTTP status ("403" -> "403*"), or "*"; weights are ints
// >= 0 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup.
func ParseWeights(entries []string) (Weights, error) {
out := make(Weights, len(entries))
for _, entry := range entries {
key, weightStr, ok := strings.Cut(entry, ":")
if !ok {
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
}
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
if err != nil || weight < 0 {
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
}
key = strings.TrimSpace(key)
if !validWeightKey(key) {
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
}
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
if len(key) == 3 && isAllDigits(key) {
key += "*"
}
out[key] = weight
}
return out, nil
}
// WeightFor returns the strike weight for an ntfy error code, longest-match-wins (exact > family > "*").
// If nothing matches (no "*" catch-all) it returns the implied default 1, so a forgotten "*" still
// bans; use "*:0" to exempt everything not explicitly weighted.
func (w Weights) WeightFor(errorCode int) int {
code := strconv.Itoa(errorCode)
weight, bestLen, matched := 0, -1, false
for key, wt := range w {
matchLen := -1
switch {
case key == "*":
matchLen = 0
case strings.HasSuffix(key, "*"):
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
matchLen = len(prefix)
}
case key == code:
matchLen = len(code)
}
if matchLen > bestLen {
weight, bestLen, matched = wt, matchLen, true
}
}
if !matched {
return 1
}
return weight
}
// validWeightKey reports whether key is a legal matcher: "*", an all-digits code, or DIGITS*.
func validWeightKey(key string) bool {
if key == "*" {
return true
}
digits := strings.TrimSuffix(key, "*")
return digits != "" && isAllDigits(digits)
}
func isAllDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}
+73
View File
@@ -0,0 +1,73 @@
package ban
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseWeights(t *testing.T) {
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
weights, err := ParseWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
require.NoError(t, err)
require.Equal(t, Weights{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
// A bare 3-digit HTTP status normalizes to its family.
weights, err = ParseWeights([]string{"429:5"})
require.NoError(t, err)
require.Equal(t, Weights{"429*": 5}, weights)
// An explicit family key stays as-is.
weights, err = ParseWeights([]string{"429*:5"})
require.NoError(t, err)
require.Equal(t, Weights{"429*": 5}, weights)
// Weight 0 is valid and means exempt.
weights, err = ParseWeights([]string{"42908:0"})
require.NoError(t, err)
require.Equal(t, Weights{"42908": 0}, weights)
_, err = ParseWeights([]string{"401"}) // Missing weight
require.Error(t, err)
_, err = ParseWeights([]string{"401:-1"}) // Negative weight
require.Error(t, err)
_, err = ParseWeights([]string{"401:abc"}) // Non-integer weight
require.Error(t, err)
_, err = ParseWeights([]string{"abc:10"}) // Non-numeric key
require.Error(t, err)
_, err = ParseWeights([]string{"4*3:10"}) // Star not at the end
require.Error(t, err)
}
func TestWeights_WeightFor(t *testing.T) {
weights, err := ParseWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
require.NoError(t, err)
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
require.Equal(t, 0, weights.WeightFor(42908))
require.Equal(t, 10, weights.WeightFor(42909))
require.Equal(t, 1, weights.WeightFor(42901))
require.Equal(t, 2, weights.WeightFor(40311))
require.Equal(t, 1, weights.WeightFor(40011))
require.Equal(t, 1, weights.WeightFor(50312))
// No rule matches (this config has 4*/5* but no "*"), so the implied default weight 1 applies.
require.Equal(t, 1, weights.WeightFor(30012))
}
func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) {
// With no "*" rule, a code that matches nothing defaults to weight 1 (can be banned), so the
// feature can't be silently turned into a no-op by forgetting "*". Explicit codes still win.
weights, err := ParseWeights([]string{"42908:0", "42909:10"})
require.NoError(t, err)
require.Equal(t, 0, weights.WeightFor(42908)) // explicitly exempt
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
require.Equal(t, 1, weights.WeightFor(42901)) // unmatched -> implied 1
require.Equal(t, 1, weights.WeightFor(40001)) // unmatched -> implied 1
}
func TestWeights_WeightFor_ExplicitStarZeroExemptsAll(t *testing.T) {
// An explicit "*:0" is the opt-out: exempt everything not otherwise weighted.
weights, err := ParseWeights([]string{"42909:10", "*:0"})
require.NoError(t, err)
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
require.Equal(t, 0, weights.WeightFor(42901)) // *:0 -> exempt everything else
}
+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,
+5 -3
View File
@@ -2,9 +2,6 @@ package cmd
import (
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/util"
"net/http"
"net/http/httptest"
"os"
@@ -14,9 +11,14 @@ import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/util"
)
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
t.Skip("temporarily disabled") // FIXME
testMessage := util.RandomString(10)
app, _, _, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
+76 -14
View File
@@ -10,6 +10,7 @@ import (
"net"
"net/netip"
"net/url"
"path/filepath"
"runtime"
"strings"
"text/template"
@@ -17,6 +18,7 @@ import (
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/server"
@@ -40,6 +42,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
@@ -51,7 +54,8 @@ 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.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
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)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
@@ -70,6 +74,7 @@ var flagsServe = append(
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)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-verify", Aliases: []string{"smtp_sender_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
@@ -84,15 +89,21 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily bandwidth limit per visitor, for attachment downloads/uploads and messages replayed from the cache by poll requests"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-topic-creation-limit-burst", Aliases: []string{"visitor_topic_creation_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST"}, Value: server.DefaultVisitorTopicCreationLimitBurst, Usage: "burst of new topic creations per visitor (0 = disabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before an offender is banned"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
@@ -145,6 +156,7 @@ func execServe(c *cli.Context) error {
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
databaseURL := c.String("database-url")
databaseReplicaURLs := c.StringSlice("database-replica-urls")
webPushPrivateKey := c.String("web-push-private-key")
webPushPublicKey := c.String("web-push-public-key")
webPushFile := c.String("web-push-file")
@@ -163,6 +175,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")
@@ -182,6 +195,7 @@ func execServe(c *cli.Context) error {
smtpSenderUser := c.String("smtp-sender-user")
smtpSenderPass := c.String("smtp-sender-pass")
smtpSenderFrom := c.String("smtp-sender-from")
smtpSenderVerify := c.Bool("smtp-sender-verify")
smtpServerListen := c.String("smtp-server-listen")
smtpServerDomain := c.String("smtp-server-domain")
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
@@ -203,8 +217,14 @@ func execServe(c *cli.Context) error {
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
visitorTopicCreationLimitBurst := c.Int("visitor-topic-creation-limit-burst")
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
banFile := c.String("ban-file")
banWindowStr := c.String("ban-window")
banThreshold := c.Int("ban-threshold")
banWeightsRaw := c.StringSlice("ban-weights")
behindProxy := c.Bool("behind-proxy")
proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
@@ -248,6 +268,10 @@ func execServe(c *cli.Context) error {
if err != nil {
return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
}
visitorTopicCreationLimitReplenish, err := util.ParseDuration(visitorTopicCreationLimitReplenishStr)
if err != nil {
return fmt.Errorf("invalid visitor topic creation limit replenish: %s", visitorTopicCreationLimitReplenishStr)
}
webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr)
if err != nil {
return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr)
@@ -256,6 +280,16 @@ func execServe(c *cli.Context) error {
if err != nil {
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
}
banWindow, err := util.ParseDuration(banWindowStr)
if err != nil {
return fmt.Errorf("invalid ban window: %s", banWindowStr)
}
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
banWeights, err := ban.ParseWeights(banWeightsRaw)
if err != nil {
return err
}
// Convert sizes to bytes
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
@@ -282,7 +316,13 @@ func execServe(c *cli.Context) error {
}
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
if databaseURL != "" && !strings.HasPrefix(databaseURL, "postgres://") && !strings.HasPrefix(databaseURL, "postgresql://") {
return errors.New("if database-url is set, it must start with postgres:// or postgresql://")
} else if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") {
return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set")
} else if len(databaseReplicaURLs) > 0 && databaseURL == "" {
return errors.New("database-replica-urls can only be used if database-url is also set")
} else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
@@ -302,6 +342,8 @@ func execServe(c *cli.Context) error {
return errors.New("if listen-https is set, both key-file and cert-file must be set")
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
} else if smtpSenderVerify && smtpSenderAddr == "" {
return errors.New("if smtp-sender-verify is set, smtp-sender-addr must also be set")
} else if smtpServerListen != "" && smtpServerDomain == "" {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
@@ -323,8 +365,8 @@ func execServe(c *cli.Context) error {
return errors.New("if upstream-base-url is set, base-url must also be set")
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
} else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
} else if authFile == "" && databaseURL == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file or database-url is not set")
} else if enableSignup && !enableLogin {
return errors.New("cannot set enable-signup without also setting enable-login")
} else if requireLogin && !enableLogin {
@@ -333,8 +375,8 @@ func execServe(c *cli.Context) error {
return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)")
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || (authFile == "" && databaseURL == "")) {
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file (or database-url) must also be set")
} else if messageSizeLimit > server.DefaultMessageSizeLimit {
log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients")
if messageSizeLimit > 5*1024*1024 {
@@ -350,6 +392,14 @@ func execServe(c *cli.Context) error {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
} else if banFile != "" && banWindow <= 0 {
return errors.New("if ban-file is set, ban-window must be greater than zero")
} else if banFile != "" && banThreshold <= 0 {
return errors.New("if ban-file is set, ban-threshold must be greater than zero")
} else if banFile != "" && len(banWeights) == 0 {
return errors.New("if ban-file is set, ban-weights must not be empty")
} else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) {
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
} else if runtime.GOOS == "windows" && listenUnix != "" {
return errors.New("listen-unix is not supported on Windows")
}
@@ -414,6 +464,15 @@ func execServe(c *cli.Context) error {
payments.Setup(stripeSecretKey)
}
// Parse Twilio template
var twilioCallFormatTemplate *template.Template
if twilioCallFormat != "" {
twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat)
if err != nil {
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
}
}
// Add default forbidden topics
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
@@ -439,6 +498,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
@@ -454,6 +514,7 @@ func execServe(c *cli.Context) error {
conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass
conf.SMTPSenderFrom = smtpSenderFrom
conf.SMTPSenderVerify = smtpSenderVerify
conf.SMTPServerListen = smtpServerListen
conf.SMTPServerDomain = smtpServerDomain
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
@@ -461,13 +522,7 @@ func execServe(c *cli.Context) error {
conf.TwilioAuthToken = twilioAuthToken
conf.TwilioPhoneNumber = twilioPhoneNumber
conf.TwilioVerifyService = twilioVerifyService
if twilioCallFormat != "" {
tmpl, err := template.New("twiml").Parse(twilioCallFormat)
if err != nil {
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
}
conf.TwilioCallFormat = tmpl
}
conf.TwilioCallFormat = twilioCallFormatTemplate
conf.MessageSizeLimit = int(messageSizeLimit)
conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit
@@ -481,8 +536,14 @@ func execServe(c *cli.Context) error {
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.VisitorTopicCreationLimitBurst = visitorTopicCreationLimitBurst
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
conf.BanFile = banFile
conf.BanWindow = banWindow
conf.BanThreshold = banThreshold
conf.BanWeights = banWeights
conf.BehindProxy = behindProxy
conf.ProxyForwardedHeader = proxyForwardedHeader
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
@@ -497,6 +558,7 @@ func execServe(c *cli.Context) error {
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.DatabaseURL = databaseURL
conf.DatabaseReplicaURLs = databaseReplicaURLs
conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile
+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
+100 -14
View File
@@ -6,13 +6,17 @@ import (
"crypto/subtle"
"errors"
"fmt"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"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"
)
@@ -30,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,
@@ -96,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
`,
},
{
@@ -255,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")
}
@@ -284,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))
@@ -311,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)
}
@@ -376,22 +463,21 @@ 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
}
var store user.Store
if databaseURL != "" {
store, err = user.NewPostgresStore(databaseURL)
host, dbErr := pg.Open(databaseURL)
if dbErr != nil {
return nil, dbErr
}
return user.NewPostgresManager(db.New(host, nil), authConfig)
} else if authFile != "" {
if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
}
store, err = user.NewSQLiteStore(authFile, authStartupQueries)
} else {
return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server")
return user.NewSQLiteManager(authFile, authStartupQueries, authConfig)
}
if err != nil {
return nil, err
}
return user.NewManager(store, authConfig)
return nil, errors.New("option database-url or auth-file not set; auth is unconfigured for this server")
}
func readPasswordAndConfirm(c *cli.Context) (string, error) {
+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
}
+146
View File
@@ -0,0 +1,146 @@
package db
import (
"context"
"database/sql"
"sync/atomic"
"time"
"heckel.io/ntfy/v2/log"
)
const (
tag = "db"
replicaHealthCheckInitialDelay = 5 * time.Second
replicaHealthCheckInterval = 30 * time.Second
replicaHealthCheckTimeout = 10 * time.Second
)
// DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods
// delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica
// (round-robin), falling back to the primary if no replicas are configured or all are unhealthy.
type DB struct {
primary *Host
replicas []*Host
counter atomic.Uint64
cancel context.CancelFunc
}
// New creates a new DB that wraps the given primary and optional replica connections.
// If replicas is nil or empty, ReadOnly() simply returns the primary.
// Replicas start unhealthy and are checked immediately by a background goroutine.
func New(primary *Host, replicas []*Host) *DB {
ctx, cancel := context.WithCancel(context.Background())
d := &DB{
primary: primary,
replicas: replicas,
cancel: cancel,
}
if len(d.replicas) > 0 {
go d.healthCheckLoop(ctx)
}
return d
}
// Query delegates to the primary database.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
return d.primary.DB.Query(query, args...)
}
// QueryRow delegates to the primary database.
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
return d.primary.DB.QueryRow(query, args...)
}
// Exec delegates to the primary database.
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
return d.primary.DB.Exec(query, args...)
}
// Begin delegates to the primary database.
func (d *DB) Begin() (*sql.Tx, error) {
return d.primary.DB.Begin()
}
// Ping delegates to the primary database.
func (d *DB) Ping() error {
return d.primary.DB.Ping()
}
// Primary returns the underlying primary *sql.DB. This is only intended for
// one-time schema setup during store initialization, not for regular queries.
func (d *DB) Primary() *sql.DB {
return d.primary.DB
}
// ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy
// replicas. If all replicas are unhealthy or none are configured, the primary is returned.
func (d *DB) ReadOnly() *sql.DB {
if len(d.replicas) == 0 {
return d.primary.DB
}
n := len(d.replicas)
start := int(d.counter.Add(1) - 1)
for i := 0; i < n; i++ {
r := d.replicas[(start+i)%n]
if r.healthy.Load() {
return r.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()
for _, r := range d.replicas {
r.DB.Close()
}
return d.primary.DB.Close()
}
// healthCheckLoop checks replicas immediately, then periodically on a ticker.
func (d *DB) healthCheckLoop(ctx context.Context) {
select {
case <-ctx.Done():
return
case <-time.After(replicaHealthCheckInitialDelay):
d.checkReplicas(ctx)
}
for {
select {
case <-ctx.Done():
return
case <-time.After(replicaHealthCheckInterval):
d.checkReplicas(ctx)
}
}
}
// checkReplicas pings each replica with a timeout and updates its health status.
func (d *DB) checkReplicas(ctx context.Context) {
for _, r := range d.replicas {
wasHealthy := r.healthy.Load()
pingCtx, cancel := context.WithTimeout(ctx, replicaHealthCheckTimeout)
err := r.DB.PingContext(pingCtx)
cancel()
if err != nil {
r.healthy.Store(false)
log.Tag(tag).Error("Database replica %s is unhealthy: %s", r.Addr, err)
} else {
r.healthy.Store(true)
if !wasHealthy {
log.Tag(tag).Info("Database replica %s is healthy", r.Addr)
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
package pg
import (
"database/sql"
"fmt"
"net/url"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
"heckel.io/ntfy/v2/db"
)
// Advisory lock keys. PostgreSQL advisory locks share one database-wide key space, so every
// ntfy key is defined here, following the "ntfy"+2586+letter scheme
const (
SchemaLockKey = int64(0x6e7466792586a) // Schema setup serialization (transaction-scoped, see db/schema)
)
// Open opens a PostgreSQL connection pool for a primary database. It pings the database
// to verify connectivity before returning.
func Open(dsn string) (*db.Host, error) {
d, err := open(dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := d.DB.Ping(); err != nil {
return nil, fmt.Errorf("database ping failed on %v: %w", d.Addr, err)
}
return d, nil
}
// OpenReplica opens a PostgreSQL connection pool for a read replica. Unlike Open, it does
// not ping the database, since replicas are health-checked in the background by db.DB.
func OpenReplica(dsn string) (*db.Host, error) {
return open(dsn)
}
// open opens a PostgreSQL database connection pool from a DSN string. It supports custom
// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns,
// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from
// the DSN before passing it to the driver.
func open(dsn string) (*db.Host, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("invalid database URL: %w", err)
}
switch u.Scheme {
case "postgres", "postgresql":
// OK
default:
return nil, fmt.Errorf("invalid database URL scheme %q, must be \"postgres\" or \"postgresql\" (URL: %s)", u.Scheme, censorPassword(u))
}
q := u.Query()
maxOpenConns, err := extractIntParam(q, "pool_max_conns", 10)
if err != nil {
return nil, err
}
maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0)
if err != nil {
return nil, err
}
connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0)
if err != nil {
return nil, err
}
connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0)
if err != nil {
return nil, err
}
u.RawQuery = q.Encode()
d, err := sql.Open("pgx", u.String())
if err != nil {
return nil, err
}
d.SetMaxOpenConns(maxOpenConns)
if maxIdleConns > 0 {
d.SetMaxIdleConns(maxIdleConns)
}
if connMaxLifetime > 0 {
d.SetConnMaxLifetime(connMaxLifetime)
}
if connMaxIdleTime > 0 {
d.SetConnMaxIdleTime(connMaxIdleTime)
}
return &db.Host{
Addr: u.Host,
DB: d,
}, nil
}
func extractIntParam(q url.Values, key string, defaultValue int) (int, error) {
s := q.Get(key)
if s == "" {
return defaultValue, nil
}
q.Del(key)
v, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err)
}
return v, nil
}
// censorPassword returns a string representation of the URL with the password replaced by "*****".
func censorPassword(u *url.URL) string {
if password, hasPassword := u.User.Password(); hasPassword {
return strings.Replace(u.String(), ":"+password+"@", ":*****@", 1)
}
return u.String()
}
func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) {
s := q.Get(key)
if s == "" {
return defaultValue, nil
}
q.Del(key)
d, err := time.ParseDuration(s)
if err != nil {
return 0, fmt.Errorf("invalid %s value %q: %w", key, s, err)
}
return d, nil
}
+53
View File
@@ -0,0 +1,53 @@
package pg
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
func TestOpen_InvalidScheme(t *testing.T) {
_, err := Open("postgresql+psycopg2://user:pass@localhost/db")
require.Error(t, err)
require.Contains(t, err.Error(), `invalid database URL scheme "postgresql+psycopg2"`)
require.Contains(t, err.Error(), "*****")
require.NotContains(t, err.Error(), "pass")
}
func TestOpen_InvalidURL(t *testing.T) {
_, err := Open("not a valid url\x00")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid database URL")
}
func TestCensorPassword(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "with password",
url: "postgres://user:secret@localhost/db",
expected: "postgres://user:*****@localhost/db",
},
{
name: "without password",
url: "postgres://localhost/db",
expected: "postgres://localhost/db",
},
{
name: "user only",
url: "postgres://user@localhost/db",
expected: "postgres://user@localhost/db",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.url)
require.NoError(t, err)
require.Equal(t, tt.expected, censorPassword(u))
})
}
}
+111
View File
@@ -0,0 +1,111 @@
// Package schema tracks and migrates database schemas, and Migrate creates or upgrades a
// store's schema inside a single transaction. On PostgreSQL, all stores share one database, so
// versions live in a shared schema_version table keyed by store name. On SQLite, every store is
// its own database file, so the version lives in the schemaVersion table keyed by id = 1.
package schema
import (
"database/sql"
"errors"
"fmt"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
)
const (
tag = "schema"
)
const (
sqliteCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`
sqliteSelectVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
sqliteUpsertVersionQuery = `INSERT INTO schemaVersion (id, version) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET version = excluded.version`
postgresCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schema_version (store TEXT PRIMARY KEY, version INT NOT NULL)`
postgresSelectVersionQuery = `SELECT version FROM schema_version WHERE store = $1`
postgresUpsertVersionQuery = `INSERT INTO schema_version (store, version) VALUES ($1, $2) ON CONFLICT (store) DO UPDATE SET version = EXCLUDED.version`
postgresAdvisoryLockQuery = `SELECT pg_advisory_xact_lock($1)` // Transaction-scoped lock to avoid migration races
)
// Migrate creates or upgrades the named store's schema to targetVersion in one transaction, or
// creates a new database using the "create" function.
func Migrate(db *sql.DB, dialect Dialect, store string, targetVersion int, create MigrateFunc, migrations map[int]MigrateFunc) error {
if dialect != Postgres && dialect != SQLite {
return fmt.Errorf("unsupported schema dialect %d", dialect)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("cannot begin %s schema transaction: %w", store, err)
}
defer tx.Rollback()
if dialect == Postgres {
// Serialize setup across nodes: CREATE TABLE IF NOT EXISTS is not atomic, and
// concurrently cold-booting nodes would otherwise race on DDL and crash
if _, err := tx.Exec(postgresAdvisoryLockQuery, pg.SchemaLockKey); err != nil {
return fmt.Errorf("cannot acquire %s schema advisory lock: %w", store, err)
}
}
if _, err := tx.Exec(createVersionTableQuery(dialect)); err != nil {
return fmt.Errorf("cannot create schema version table: %w", err)
}
version, err := readVersion(tx, dialect, store)
if errors.Is(err, sql.ErrNoRows) {
// Fresh database: create the store's tables at the target version
if err := create(tx); err != nil {
return fmt.Errorf("cannot create %s schema: %w", store, err)
}
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
return fmt.Errorf("cannot write %s schema version: %w", store, err)
}
return tx.Commit()
} else if err != nil {
return fmt.Errorf("cannot read %s schema version: %w", store, err)
}
if version == targetVersion {
return tx.Commit()
}
if version > targetVersion {
return fmt.Errorf("unexpected %s schema version %d, this version of ntfy supports up to %d", store, version, targetVersion)
}
for v := version; v < targetVersion; v++ {
migrate, ok := migrations[v]
if !ok {
return fmt.Errorf("cannot find %s migration step from version %d to %d", store, v, v+1)
}
log.Tag(tag).Info("Migrating %s database schema: from %d to %d", store, v, v+1)
if err := migrate(tx); err != nil {
return fmt.Errorf("%s migration step from version %d to %d failed: %w", store, v, v+1, err)
}
}
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
return fmt.Errorf("cannot write %s schema version: %w", store, err)
}
return tx.Commit()
}
func createVersionTableQuery(dialect Dialect) string {
if dialect == Postgres {
return postgresCreateVersionTableQuery
}
return sqliteCreateVersionTableQuery
}
func readVersion(tx *sql.Tx, dialect Dialect, store string) (version int, err error) {
if dialect == Postgres {
err = tx.QueryRow(postgresSelectVersionQuery, store).Scan(&version)
} else {
err = tx.QueryRow(sqliteSelectVersionQuery).Scan(&version)
}
return
}
func writeVersion(tx *sql.Tx, dialect Dialect, store string, version int) error {
var err error
if dialect == Postgres {
_, err = tx.Exec(postgresUpsertVersionQuery, store, version)
} else {
_, err = tx.Exec(sqliteUpsertVersionQuery, version)
}
return err
}
+190
View File
@@ -0,0 +1,190 @@
package schema_test
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/db/schema"
dbtest "heckel.io/ntfy/v2/db/test"
_ "github.com/mattn/go-sqlite3"
)
const testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
func testCreate(tx *sql.Tx) error {
_, err := tx.Exec(testCreateQuery)
return err
}
func openTestPostgres(t *testing.T) *sql.DB {
t.Helper()
host, err := pg.Open(dbtest.CreateTestPostgresSchema(t))
require.Nil(t, err)
t.Cleanup(func() { host.DB.Close() })
return host.DB
}
func openTestSQLite(t *testing.T) *sql.DB {
t.Helper()
d, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
require.Nil(t, err)
t.Cleanup(func() { d.Close() })
return d
}
func forEachDialect(t *testing.T, f func(t *testing.T, d *sql.DB, dialect schema.Dialect)) {
t.Run("postgres", func(t *testing.T) {
f(t, openTestPostgres(t), schema.Postgres)
})
t.Run("sqlite", func(t *testing.T) {
f(t, openTestSQLite(t), schema.SQLite)
})
}
func TestMigrate_FreshCreate(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
// A fresh database jumps straight to the target version; migration steps are not consulted
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
require.Nil(t, err)
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
// Idempotent: a second node boots against the migrated schema
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
})
}
func TestMigrate_AppliesMigrationSteps(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
// A newer version of the code migrates 1 -> 3 step by step, in order
migrations := map[int]schema.MigrateFunc{
1: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
return err
},
2: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN size INT NOT NULL DEFAULT 0`)
return err
},
}
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, migrations))
_, err := d.Exec(`INSERT INTO things (id, name, color, size) VALUES ('b', 'thing b', 'red', 2)`)
require.Nil(t, err)
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
})
}
func TestMigrate_ClosureCarriesConfig(t *testing.T) {
// Migrations needing config take it via closure at map-construction time; there is no
// params plumbing in the framework itself
migrationsFor := func(defaultName string) map[int]schema.MigrateFunc {
return map[int]schema.MigrateFunc{
1: schema.AsMigrateFunc(fmt.Sprintf(`ALTER TABLE things ADD COLUMN nick TEXT NOT NULL DEFAULT '%s'`, defaultName)),
}
}
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
require.Nil(t, err)
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, migrationsFor("configured-default")))
var nick string
require.Nil(t, d.QueryRow(`SELECT nick FROM things WHERE id = 'a'`).Scan(&nick))
require.Equal(t, "configured-default", nick)
})
}
func TestMigrate_InvalidDialect(t *testing.T) {
d := openTestSQLite(t)
err := schema.Migrate(d, schema.Dialect(99), "things", 1, testCreate, nil)
require.Error(t, err)
}
func TestMigrate_RefusesFutureVersion(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, map[int]schema.MigrateFunc{}))
err := schema.Migrate(d, dialect, "things", 1, testCreate, nil)
require.Error(t, err)
})
}
func TestMigrate_MissingStepFails(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
err := schema.Migrate(d, dialect, "things", 3, testCreate, nil) // No step 1 -> 2 registered
require.Error(t, err)
})
}
func TestMigrate_StoresAreIndependent(t *testing.T) {
// Postgres only: stores share one database, tracked as rows in schema_version. On SQLite
// every store has its own database file, so independence is by file.
d := openTestPostgres(t)
require.Nil(t, schema.Migrate(d, schema.Postgres, "things", 1, testCreate, nil))
require.Nil(t, schema.Migrate(d, schema.Postgres, "gadgets", 4, func(tx *sql.Tx) error {
_, err := tx.Exec(`CREATE TABLE IF NOT EXISTS gadgets (id TEXT PRIMARY KEY)`)
return err
}, nil))
require.Equal(t, 1, storeVersion(t, d, schema.Postgres, "things"))
require.Equal(t, 4, storeVersion(t, d, schema.Postgres, "gadgets"))
}
func TestMigrate_SQLiteReadsExistingSchemaVersionTable(t *testing.T) {
// Existing ntfy SQLite databases (message, user, webpush) track their version in a
// schemaVersion (id, version) table keyed by id = 1; the framework uses that table as-is
// on SQLite, so existing databases migrate without any adoption step
d := openTestSQLite(t)
_, err := d.Exec(testCreateQuery)
require.Nil(t, err)
_, err = d.Exec(`CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`)
require.Nil(t, err)
_, err = d.Exec(`INSERT INTO schemaVersion VALUES (1, 1)`)
require.Nil(t, err)
migrations := map[int]schema.MigrateFunc{
1: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
return err
},
}
require.Nil(t, schema.Migrate(d, schema.SQLite, "things", 2, testCreate, migrations))
_, err = d.Exec(`INSERT INTO things (id, name, color) VALUES ('a', 'thing a', 'red')`)
require.Nil(t, err)
require.Equal(t, 2, storeVersion(t, d, schema.SQLite, "things"))
}
func TestMigrate_ConcurrentFreshCreate(t *testing.T) {
// Postgres only: concurrent cold-boots must not race on DDL (CREATE TABLE IF NOT EXISTS is
// not atomic); Migrate serializes via an advisory lock. SQLite has a single writer.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
const n = 8
errs := make(chan error, n)
for i := 0; i < n; i++ {
go func() {
host, err := pg.Open(schemaDSN)
if err != nil {
errs <- err
return
}
defer host.DB.Close()
errs <- schema.Migrate(host.DB, schema.Postgres, "things", 1, testCreate, nil)
}()
}
for i := 0; i < n; i++ {
require.Nil(t, <-errs)
}
}
func storeVersion(t *testing.T, d *sql.DB, dialect schema.Dialect, store string) int {
t.Helper()
var version int
if dialect == schema.Postgres {
require.Nil(t, d.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version), fmt.Sprintf("store %s", store))
} else {
require.Nil(t, d.QueryRow(`SELECT version FROM schemaVersion WHERE id = 1`).Scan(&version), fmt.Sprintf("store %s", store))
}
return version
}
+31
View File
@@ -0,0 +1,31 @@
package schema
import "database/sql"
// Dialect selects the SQL flavor Migrate speaks to the version table.
type Dialect int
// Supported dialects; SQLite is the zero value
const (
SQLite Dialect = iota
Postgres
)
// MigrateFunc applies one schema change inside the setup transaction: the initial creation of
// a store's tables, or one step upgrading a store from version N to N+1. Migrations needing
// config capture it via closure, e.g. func migrations(cacheDuration time.Duration) map[int]MigrateFunc.
type MigrateFunc func(tx *sql.Tx) error
// AsMigrateFunc converts a simple query to a migration function
func AsMigrateFunc(query string) MigrateFunc {
return func(tx *sql.Tx) error {
_, err := tx.Exec(query)
return err
}
}
// NopMigrateFunc is a migration step that does nothing, for versions where a dialect has no
// work to do (e.g. when only the other dialect's schema changed).
func NopMigrateFunc(_ *sql.Tx) error {
return nil
}
+185
View File
@@ -0,0 +1,185 @@
package dbtest
import (
"database/sql"
"fmt"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Querier is the subset of *sql.DB / *db.DB needed to introspect a schema.
type Querier interface {
Query(query string, args ...any) (*sql.Rows, error)
}
// SQLiteSchema returns a normalized, comparable description of the database schema: tables
// with their columns, named indexes, and foreign keys. Column order, declared type spelling
// (INT vs INTEGER) and default values are not part of the description, so the schema produced
// by a migration chain can be compared to a freshly created one.
func SQLiteSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range sqliteTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, sqliteColumns(t, d, table)...)
lines = append(lines, sqliteForeignKeys(t, d, table)...)
lines = append(lines, sqliteIndexes(t, d, table)...)
}
return strings.Join(lines, "\n")
}
// PostgresSchema is SQLiteSchema's PostgreSQL counterpart, describing the current schema's
// tables, columns, constraints and indexes in a normalized, comparable way.
func PostgresSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range postgresTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, postgresColumns(t, d, table)...)
}
lines = append(lines, postgresConstraints(t, d)...)
lines = append(lines, postgresIndexes(t, d)...)
return strings.Join(lines, "\n")
}
func sqliteTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
}
func sqliteColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA table_info(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var dflt sql.NullString
require.Nil(t, rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk))
typ = strings.ToUpper(typ)
if typ == "INT" { // INT and INTEGER are the same affinity; migrations spell them inconsistently
typ = "INTEGER"
}
lines = append(lines, fmt.Sprintf(" col %s %s notnull=%d pk=%d", name, typ, notNull, pk))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteForeignKeys(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA foreign_key_list(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var id, seq int
var refTable, from, onUpdate, onDelete, match string
var to sql.NullString // NULL when referencing the parent's primary key implicitly
require.Nil(t, rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &match))
lines = append(lines, fmt.Sprintf(" fk %s -> %s(%s) on_delete=%s", from, refTable, to.String, onDelete))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteIndexes(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA index_list(%q)`, table))
require.Nil(t, err)
type index struct {
name string
unique, partial int
}
indexes := make([]index, 0)
for rows.Next() {
var seq, unique, partial int
var name, origin string
require.Nil(t, rows.Scan(&seq, &name, &unique, &origin, &partial))
// Skip auto-indexes backing PRIMARY KEY/UNIQUE table constraints; those are described
// by the column and constraint listings already
if strings.HasPrefix(name, "sqlite_autoindex_") {
continue
}
indexes = append(indexes, index{name, unique, partial})
}
require.Nil(t, rows.Err())
require.Nil(t, rows.Close())
lines := make([]string, 0, len(indexes))
for _, idx := range indexes {
cols := queryStrings(t, d, fmt.Sprintf(`SELECT name FROM pragma_index_info(%q) ORDER BY seqno`, idx.name))
lines = append(lines, fmt.Sprintf(" index %s unique=%d partial=%d cols=(%s)", idx.name, idx.unique, idx.partial, strings.Join(cols, ",")))
}
sort.Strings(lines)
return lines
}
func postgresTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name`)
}
func postgresColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`, table)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, typ, nullable string
require.Nil(t, rows.Scan(&name, &typ, &nullable))
lines = append(lines, fmt.Sprintf(" col %s %s nullable=%s", name, typ, nullable))
}
require.Nil(t, rows.Err())
return lines
}
func postgresConstraints(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `
SELECT 'constraint ' || conrelid::regclass::text || ': ' || pg_get_constraintdef(oid)
FROM pg_constraint
WHERE connamespace = current_schema()::regnamespace
ORDER BY 1
`)
}
func postgresIndexes(t testing.TB, d Querier) []string {
t.Helper()
rows, err := d.Query(`SELECT indexname, indexdef, schemaname FROM pg_indexes WHERE schemaname = current_schema() ORDER BY indexname`)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, def, schema string
require.Nil(t, rows.Scan(&name, &def, &schema))
// The index definition qualifies the table with the (test-specific) schema name; strip
// it so snapshots from different test schemas compare equal
def = strings.ReplaceAll(def, schema+".", "")
lines = append(lines, "index "+def)
}
require.Nil(t, rows.Err())
return lines
}
func queryStrings(t testing.TB, d Querier, query string) []string {
t.Helper()
rows, err := d.Query(query)
require.Nil(t, err)
defer rows.Close()
values := make([]string, 0)
for rows.Next() {
var value string
require.Nil(t, rows.Scan(&value))
values = append(values, value)
}
require.Nil(t, rows.Err())
return values
}
+64
View File
@@ -0,0 +1,64 @@
package dbtest
import (
"fmt"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/util"
)
const testPoolMaxConns = "2"
// CreateTestPostgresSchema creates a temporary PostgreSQL schema and returns the DSN pointing to it.
// It registers a cleanup function to drop the schema when the test finishes.
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
func CreateTestPostgresSchema(t *testing.T) string {
t.Helper()
dsn := os.Getenv("NTFY_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("NTFY_TEST_DATABASE_URL not set")
}
schema := fmt.Sprintf("test_%s", util.RandomString(10))
u, err := url.Parse(dsn)
require.Nil(t, err)
q := u.Query()
q.Set("pool_max_conns", testPoolMaxConns)
u.RawQuery = q.Encode()
dsn = u.String()
setupHost, err := pg.Open(dsn)
require.Nil(t, err)
_, err = setupHost.DB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
require.Nil(t, err)
require.Nil(t, setupHost.DB.Close())
q.Set("search_path", schema)
u.RawQuery = q.Encode()
schemaDSN := u.String()
t.Cleanup(func() {
cleanHost, err := pg.Open(dsn)
if err == nil {
cleanHost.DB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
cleanHost.DB.Close()
}
})
return schemaDSN
}
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it.
// It registers cleanup functions to close the DB and drop the schema when the test finishes.
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
func CreateTestPostgres(t *testing.T) *db.DB {
t.Helper()
schemaDSN := CreateTestPostgresSchema(t)
testHost, err := pg.Open(schemaDSN)
require.Nil(t, err)
d := db.New(testHost, nil)
t.Cleanup(func() {
d.Close()
})
return d
}
+25
View File
@@ -0,0 +1,25 @@
package db
import (
"database/sql"
"sync/atomic"
)
// Beginner is an interface for types that can begin a database transaction.
// Both *sql.DB and *DB implement this.
type Beginner interface {
Begin() (*sql.Tx, error)
}
// Querier is an interface for types that can execute SQL queries.
// *sql.DB, *sql.Tx, and *DB all implement this.
type Querier interface {
Query(query string, args ...any) (*sql.Rows, error)
}
// Host pairs a *sql.DB with the host:port it was opened against.
type Host struct {
Addr string // "host:port"
DB *sql.DB
healthy atomic.Bool
}
+36
View File
@@ -0,0 +1,36 @@
package db
import "database/sql"
// ExecTx executes a function within a database transaction. If the function returns an error,
// the transaction is rolled back. Otherwise, the transaction is committed.
func ExecTx(db Beginner, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := f(tx); err != nil {
return err
}
return tx.Commit()
}
// QueryTx executes a function within a database transaction and returns the result. If the function
// returns an error, the transaction is rolled back. Otherwise, the transaction is committed.
func QueryTx[T any](db Beginner, f func(tx *sql.Tx) (T, error)) (T, error) {
tx, err := db.Begin()
if err != nil {
var zero T
return zero, err
}
defer tx.Rollback()
t, err := f(tx)
if err != nil {
return t, err
}
if err := tx.Commit(); err != nil {
return t, err
}
return t, nil
}
+707 -121
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -28,7 +28,7 @@ via the following channels:
| Channel | Contact | Description |
|-----------------------|-----------------------------------------------------|------------------------------------------|
| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues |
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh) | Inquire about billing issues |
| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels |
Please include your ntfy.sh username when contacting support so we can verify your subscription status.
@@ -37,6 +37,11 @@ Please include your ntfy.sh username when contacting support so we can verify yo
If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).
## Abuse reports
To report spam, phishing, or other abuse of ntfy.sh, please email [abuse@mail.ntfy.sh](mailto:abuse@mail.ntfy.sh).
Please include the topic name and any relevant message details so we can investigate.
## Other inquiries
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
+7 -4
View File
@@ -65,8 +65,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
``` shell
wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz
wget https://go.dev/dl/go1.25.8.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.25.8.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
go version # verifies that it worked
```
@@ -77,9 +77,11 @@ go install github.com/goreleaser/goreleaser@latest
goreleaser -v # verifies that it worked
```
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)).
Use a current LTS release (Node 24 is what CI builds with; anything older than Node 20 will not work
with the current Vite-based web build):
``` shell
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
npm -v # verifies that it worked
```
@@ -255,6 +257,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> \
+29 -1
View File
@@ -641,6 +641,32 @@ or by simply providing traccar with a valid username/password combination.
<entry key='sms.http.password'>mypass</entry>
```
## Flowtriq DDoS detection
[Flowtriq](https://flowtriq.com) is a real-time DDoS detection and mitigation platform. Its Linux agent, ftagent,
supports webhook alerts that can POST directly to an ntfy topic, so you get push notifications on your phone
whenever an attack is detected.
Configure the webhook URL in your ftagent configuration to point to your ntfy topic:
```yaml
# /etc/ftagent/ftagent.yml
alerts:
webhooks:
- url: https://ntfy.sh/flowtriq-attacks
method: POST
```
You can also use curl to test the integration manually with a sample attack alert:
```bash
curl \
-H "Title: DDoS Attack Detected" \
-H "Priority: urgent" \
-H "Tags: rotating_light" \
-d "Attack detected on 203.0.113.5: 14.2 Gbps UDP flood from 3,482 sources" \
ntfy.sh/flowtriq-attacks
```
## Terminal Notifications for Long-Running Commands
This example provides a simple way to send notifications using [ntfy.sh](https://ntfy.sh) when a terminal command completes. It includes success or failure indicators based on the command's exit status.
@@ -661,6 +687,8 @@ Add the following function and alias to your `.bashrc` or `.bash_profile`:
local token=$(< ~/.ntfy_token) # Securely read the token
local status_icon="$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)"
local last_command=$(history | tail -n1 | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')
# for zsh users, use the same sed pattern but get the history differently.
# local last_command=$(history "$HISTCMD" | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')
curl -s -X POST "https://n.example.dev/alerts" \
-H "Authorization: Bearer $token" \
@@ -692,4 +720,4 @@ To test failure notifications:
false; alert # Always fails (exit 1)
ls --invalid; alert # Invalid option
cat nonexistent_file; alert # File not found
```
```
+2 -1
View File
@@ -71,7 +71,8 @@ The web app is a static website without a backend (other than the ntfy API). All
cache and local storage. That means it does not need to be protected with a login screen, and it poses no additional
security risk. So technically, it does not need to be disabled.
However, if you still want to disable it, you can do so with the `web-root: disable` option in the `server.yml` file.
However, if you still want, you can require login with the `require-login: true` option,
or disable it with the `web-root: disable` option in the `server.yml` file.
Think of the ntfy web app like an Android/iOS app. It is freely available and accessible to anyone, yet useless without
a proper backend. So as long as you secure your backend with ACLs, exposing the ntfy web app to the Internet is harmless.
+146 -30
View File
@@ -28,42 +28,130 @@ resources to get started. _I am not affiliated with Kris or Alex, I just liked t
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
deb/rpm packages.
### Download and run
The steps below allow you to download ntfy server and run it in a pinch. But it won't be enough to install it permanently
as a service starting at boot time.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.tar.gz
tar zxvf ntfy_2.17.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.17.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.tar.gz
tar zxvf ntfy_2.28.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.28.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.tar.gz
tar zxvf ntfy_2.17.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.17.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.tar.gz
tar zxvf ntfy_2.28.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.28.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.tar.gz
tar zxvf ntfy_2.17.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.17.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.tar.gz
tar zxvf ntfy_2.28.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.28.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.tar.gz
tar zxvf ntfy_2.17.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.17.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.17.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.tar.gz
tar zxvf ntfy_2.28.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.28.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
### Install as a service
If you want to install ntfy server permanently as a service, and your OS/distribution of choice doesn't offer a package,
there are a few more steps to follow.
Create the ntfy user and group:
```bash
useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy
```
Depending on your init system, the following steps will diverge.
#### On systemd systems
Install the ntfy server unit file (which contains parameters to start the service at boot time):
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.28.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
sudo mv ntfy_2.28.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.28.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
sudo mv ntfy_2.28.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
Then notify systemd we have added a new service and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl start ntfy
```
#### On OpenRC systems
Install the ntfy server service script:
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.28.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
sudo mv ntfy_2.28.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.28.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
sudo mv ntfy_2.28.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
Start the ntfy server service:
```bash
sudo rc-service ntfy start
```
Add the ntfy server service to the default runlevel (so that it starts at boot time):
```bash
sudo rc-update add ntfy default
```
## Debian/Ubuntu repository
!!! info
@@ -116,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -124,7 +212,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -132,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,7 +228,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -150,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -213,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.17.0/ntfy_2.17.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.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.17.0/ntfy_2.17.0_darwin_all.tar.gz > ntfy_2.17.0_darwin_all.tar.gz
tar zxvf ntfy_2.17.0_darwin_all.tar.gz
sudo cp -a ntfy_2.17.0_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_darwin_all.tar.gz > ntfy_2.28.0_darwin_all.tar.gz
tar zxvf ntfy_2.28.0_darwin_all.tar.gz
sudo cp -a ntfy_2.28.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.17.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.28.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -245,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.17.0/ntfy_2.17.0_windows_amd64.zip),
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.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`
@@ -638,3 +726,31 @@ kubectl apply -k /ntfy
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
```
## Helm
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
If you prefer [Helm](https://helm.sh/), ntfy can be deployed using the third-party
[HelmForge chart](https://helmforge.dev/docs/charts/ntfy), which packages the official
`binwiederhier/ntfy` image with persistent storage, Service, Ingress, optional Prometheus metrics, and more.
This chart is **not** maintained by the ntfy developers.
!!! warning
The HelmForge project is young and maintained by a small community. Review the chart before deploying it,
and use it at your own risk.
```bash
helm repo add helmforge https://repo.helmforge.dev
helm repo update
helm install ntfy helmforge/ntfy
```
Alternatively, install it directly from the OCI registry:
```bash
helm install ntfy oci://ghcr.io/helmforgedev/helm/ntfy
```
Because ntfy's default SQLite storage is single-writer, run the chart as a single instance rather than
treating it as a horizontally scalable deployment. See the [chart documentation](https://helmforge.dev/docs/charts/ntfy)
for the full list of configurable values.
+11 -2
View File
@@ -43,6 +43,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Miniflux](https://miniflux.app/docs/ntfy.html) - Minimalist and opinionated feed reader
- [Beszel](https://beszel.dev/guide/notifications/ntfy) - Server monitoring platform
- [Simple Observability](https://simpleobservability.com/docs/alerts/ntfy) - Server monitoring and observability platform
- [Sifio](https://sifio.net) - Aggregate updates from RSS, social media, and other sources, then deliver them to ntfy, Slack, Notion and more
## Integration via HTTP/SMTP/etc.
@@ -54,6 +55,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Proxmox-Ntfy](https://github.com/qtsone/proxmox-ntfy) - Python script that monitors Proxmox tasks and sends notifications using the Ntfy service.
- [Scrutiny](https://github.com/AnalogJ/scrutiny) - WebUI for smartd S.M.A.R.T monitoring. Scrutiny includes shoutrrr/ntfy integration ([see integration README](https://github.com/AnalogJ/scrutiny?tab=readme-ov-file#notifications))
- [UptimeObserver](https://uptimeobserver.com) - Uptime Monitoring tool for Websites, APIs, SSL Certificates, DNS, Domain Names and Ports. [Integration Guide](https://support.uptimeobserver.com/integrations/ntfy/)
- [Flowtriq](https://flowtriq.com) - Real-time DDoS detection and mitigation platform (integration via [webhook alerts](https://flowtriq.com))
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
@@ -82,13 +84,15 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
- [ex_ntfy](https://github.com/houllette/ex_ntfy) - Elixir SDK covering publishing, polling, and streaming subscriptions for ntfy servers (Elixir)
- [ntfy-logging](https://github.com/Pimak/ntfy-logging) - Turns JVM error logs into ntfy notifications, with zero-code adapters for java.util.logging, Logback, Log4j2, Spring Boot, Micronaut and Quarkus (Java)
## CLIs + GUIs
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
- [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
@@ -98,6 +102,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
- [Ntfy App](https://github.com/rubix-studios-pty-ltd/ntfy-app) - Tauri/Rust desktop client for Windows, Linux and MacOS with push notifications.
## Projects + scripts
@@ -107,6 +112,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
- [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay) - ntfy.sh relay for Alertmanager (Go)
- [oci-notifications-ntfy-relay](https://github.com/Ryan02I5/oci-notifications-ntfy-relay) - Minimal OCI Notifications / Oracle Functions relay to ntfy topics (Python)
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
- [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs)
- [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell)
@@ -185,9 +191,12 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS)
- [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
@@ -305,7 +314,7 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!**
| URL | Country |
|---------------------------------------------------|--------------------|
| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States |
| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France |
| [ntfy.tedomum.fr](https://ntfy.tedomum.fr/) | 🇫🇷 France |
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
+9 -5
View File
@@ -1,6 +1,6 @@
# Privacy policy
**Last updated:** January 2, 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,7 +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** - Only if you subscribe to a paid plan (for billing purposes)
- **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.
@@ -76,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)
@@ -143,6 +146,7 @@ No cookies are used for tracking. The web app does not have a backend beyond the
| Attachments | 3 hours (configurable by server operators) |
| User accounts | Until you delete your account |
| Access tokens | Until you revoke them or delete your account |
| Email addresses | Until you remove them or delete your account |
| Phone numbers | Until you remove them or delete your account |
| Web push subscriptions | 60 days of inactivity, then automatically removed |
| Server logs | Varies; debugging logs are typically temporary |
+105 -13
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:
@@ -628,7 +666,7 @@ them with a comma, e.g. `tag1,tag2,tag3`.
_Supported on:_ :material-android: :material-firefox:
You can format messages using [Markdown](https://www.markdownguide.org/basic-syntax/) 🤩. That means you can use
**bold text**, *italicized text*, links, images, and more. Supported Markdown features (web app only for now):
**bold text**, *italicized text*, links, images, and more. Supported Markdown features:
- [Emphasis](https://www.markdownguide.org/basic-syntax/#emphasis) such as **bold** (`**bold**`), *italics* (`*italics*`)
- [Links](https://www.markdownguide.org/basic-syntax/#links) (`[some tool](https://ntfy.sh)`)
@@ -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"
@@ -3182,6 +3224,14 @@ You can use the following features in your templates:
A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
!!! info
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
a template that loops too long is stopped and rejected with an HTTP 400 error. Templates are
limited to 32 KB in size, `printf` widths and precisions must be below 1000 (`%999d` is
allowed, `%1000d` is not), including the `%*d` form that takes the width from an argument, and
`indent`/`nindent` are limited to 100 spaces.
### Template functions
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
@@ -3215,10 +3265,21 @@ you'd like to persist longer, or to blast-notify yourself on all possible channe
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.
Since ntfy does not provide auth (yet), 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
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
that, your IP address appears in the e-mail body. This is to prevent abuse.
!!! info
On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature,
you must verify your email in the web app's [Account section](https://ntfy.sh/account). The daily limit for
free users is **5 emails per visitor per day**.
=== "Command line (curl)"
```
curl \
@@ -3658,7 +3719,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* | `phil@example.com` | E-mail address for e-mail notifications |
| `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) |
@@ -4094,26 +4155,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"
@@ -4148,25 +4224,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"
@@ -4840,7 +4931,8 @@ but just in case, let's list them all:
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
| **Title and tag size** | The message title is limited to 1 KB, and all tags combined to 512 bytes. Requests exceeding either are rejected with HTTP 400. |
| **Daily bandwidth** | By default, the server allows 500 MB of traffic per visitor in a 24 hour period, covering attachment GET/PUT/POST traffic and messages replayed from the cache by [poll requests](subscribe/api.md#replay-limits). Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
@@ -4871,7 +4963,7 @@ table in their canonical form.
| `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body |
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address (or `yes`) for [e-mail notifications](#e-mail-notifications) |
| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
+406 -22
View File
@@ -4,14 +4,409 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Current stable releases
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.17.0 | Feb 8, 2026 |
| ntfy Android app | v1.22.2 | Jan 25, 2026 |
| ntfy iOS app | v1.3 | Nov 26, 2023 |
| Component | Version | Release date |
|------------------|---------|---------------|
| ntfy server | v2.28.0 | Aug 27, 2026 |
| ntfy Android app | v1.25.2 | July 23, 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.28.0
Released August 27, 2026
This is a hardening release. A single topic on ntfy.sh was polled continuously with `poll=1` and no
`since` cursor, which replays a topic's entire cache on every request. The changes below bound what one
replay can cost, close two fields that had no size limit at all, and fix an ordering bug found while
digging into it.
**Bug fixes + maintenance:**
* Fix messages being returned out of publish order when polling or replaying **several topics at once** (`/topic1,topic2/json?poll=1`). `Message.Time` has second granularity, so a multi-topic replay sorts many equal keys; the sort was unstable, which could shuffle a single topic's own messages. Single-topic replays were not affected ([#1297](https://github.com/binwiederhier/ntfy/issues/1297))
* Limit the message title to 1 KB and all tags combined to 512 bytes, rejecting larger requests with HTTP 400 (error codes `40057` and `40058`). Neither field had a size limit before, unlike the message body; on ntfy.sh the 99.9th percentile is 212 bytes for titles and 244 for tags
* Cap a single cache replay at 10 MB of messages per topic. A poll without a `since` cursor returns a topic's entire cache, which was previously unbounded and could reach tens of megabytes on a busy topic, so one request could allocate that much on the server. The newest messages that fit are kept and a truncated response carries an `X-Messages-Truncated: 1` header
* `visitor-attachment-daily-bandwidth-limit` now also covers messages replayed from the message cache by poll requests, not just attachment traffic. A poll without a `since` cursor returns a topic's entire cache, so a topic that is cheap to fill can be re-read for many times its own size; polls beyond the budget are rejected with HTTP 429 (error code 42905) before anything is written. **Note that heavy pollers now consume the same budget as attachment downloads**, so operators serving both may want to raise the limit
### ntfy server v2.27.0
Released August 4, 2026
This release lets you sign in with your verified email address instead of your username, which should help if you ever
signed up with an email and then forgot which username you picked. It also hardens the message templating engine against
a few ways a small template could eat a lot of memory, and it drops the "experimental" label from
[PostgreSQL support](config.md#postgresql), which has been running ntfy.sh for a while now.
I also did a bunch of refactoring in, mostly in preparation for being able to cluster ntfy nodes and scale the service
horizontally. It'll be a while until then, ... baby steps.
**Security:**
* Limit message templates (`Template: yes`) to 32 KB, limit `printf` widths and precisions to below 1000, and limit `indent`/`nindent` to 100 spaces, preventing excessive memory use from a single small template
* Exclude secrets from the config hash served to the web app, preventing a rather theoretical information leak
**Features:**
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
**Bug fixes + maintenance:**
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
* Move the Prometheus metrics into a dedicated `metrics` package
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
* Fix `user_phone` table in the SQLite user database referencing a dropped table after the v2.14 schema migration; repaired automatically by a new migration
## ntfy Android v1.25.2
Released July 23, 2026
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
once connectivity returns.
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
**Features:**
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
**Bug fixes + maintenance:**
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
### ntfy server v2.26.3
Released July 20, 2026
This is a hotfix release, useful pretty much only for ntfy.sh. It was adds the ability to track abusive IPs more
efficiently, reducing the load on the IP banning services and preventing them from falling behind and leaving abusers
unbanned for too long. It works by tracking HTTP errors, and writing out a ban file that fail2ban can read and ban
offenders instantly. See [ban-feed](config.md#ban-feed) for details.
**Features:**
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (`ban-file`, `ban-window`, `ban-threshold`, `ban-weights`; see [ban-feed docs](config.md#ban-feed))
### ntfy server v2.26.0
Released July 9, 2026
This release hardens **message templates**, which are now executed with a hard-capped execution timeout. This closes
a denial-of-service hole.
On the web app side, it adds configurable **date and time formats**, a smoother loading and page-transition experience,
and a fix that strips unsafe URL protocols from rendered Markdown.
**Security:**
* Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881), [@5ud0er](https://github.com/5ud0er) and [@jvoisin](https://github.com/jvoisin) for reporting)
**Features:**
* Web app: Add "Date format" and "Time format" settings (Settings -> Appearance), with ISO 8601, day/month/year (slash or dot) and month/day/year date options and a 12-/24-hour clock option, and base the default format on your browser/system locale rather than the selected display language. When logged in, both settings sync across devices via your account ([#1647](https://github.com/binwiederhier/ntfy/issues/1647), thanks to [@wsw70](https://github.com/wsw70) for reporting)
**Bug fixes + maintenance:**
* Web app: Smooth transitions and loading animation, remove flickering
* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
* Docs: Document the third-party HelmForge Helm chart as a Kubernetes installation option ([#1727](https://github.com/binwiederhier/ntfy/issues/1727), thanks to [@mberlofa](https://github.com/mberlofa))
* Web app: Strip unsafe URL protocols (`javascript:`, `data:`, ...) from links and images in Markdown-rendered messages, so they no longer trigger an uncaught "React has blocked a javascript: URL" error (thanks to [@jvoisin](https://github.com/jvoisin) for reporting)
## ntfy server v2.25.0
Released June 24, 2026
This release adds **password reset** via email, and reworks email verification to use durable,
link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at
signup; a user can reset their password only once they have a verified "primary" (recovery)
email.
All of this work is probably not useful for self-hosters, but it hopefully will be useful for me,
since I do have to reset accounts on a regular basis.
**Security issues:**
* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG
**Features:**
* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins
* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI
* You can now clear/read messages and delete messages with a GET request ([#1771](https://github.com/binwiederhier/ntfy/issues/1771), thanks to [@lemmi](https://github.com/lemmi) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
* Add a reload button to the web app's action bar when running as an installed PWA, which clears the service worker caches and hard-refreshes the app ([#1281](https://github.com/binwiederhier/ntfy/issues/1281), thanks to [@leanza](https://github.com/leanza) for reporting)
* Add a "Back to app" link to the web app's login, signup, and password-reset pages (alongside the existing links), which previously had no way back to the app
**Bug fixes + maintenance:**
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
* Stop silently stripping spaces from passwords while typing in the web app's login, signup, and password-reset forms ([#1246](https://github.com/binwiederhier/ntfy/issues/1246), thanks to [@aldem](https://github.com/aldem) for reporting)
* Update web app dependencies, including major-version upgrades to Vite (6 -> 8, now Rolldown-based), Material UI (5 -> 9), and Dexie (3 -> 4) ([#1800](https://github.com/binwiederhier/ntfy/pull/1800), [#1764](https://github.com/binwiederhier/ntfy/pull/1764), [#1767](https://github.com/binwiederhier/ntfy/pull/1767), [#1762](https://github.com/binwiederhier/ntfy/pull/1762), [#1766](https://github.com/binwiederhier/ntfy/pull/1766), [#1765](https://github.com/binwiederhier/ntfy/pull/1765), thanks Dependabot)
* Play notification sounds in the web app even when the Notification API is unavailable, e.g. over plain HTTP or in browsers without notification support ([#1772](https://github.com/binwiederhier/ntfy/pull/1772), thanks to [@mitya12342](https://github.com/mitya12342) for the contribution)
* Stop escaping `<`, `>`, and `&` as `\u003c`/`\u003e`/`\u0026` in JSON responses ([#1511](https://github.com/binwiederhier/ntfy/issues/1511), [#1512](https://github.com/binwiederhier/ntfy/pull/1512), thanks to [@wunter8](https://github.com/wunter8) for the contribution)
* Fix the web app navbar not reflecting a topic reservation (lock icon, and "Reserve topic" -> "Change reservation"/"Remove reservation" menu) until a page reload, by persisting reservation and display-name changes onto already-subscribed topics during account sync
* Reduce the web app's initial bundle size by ~300 KB (~50 KB gzipped) by lazy-loading the emoji picker dataset and the Markdown renderer, and by importing Material UI icons individually
## ntfy server v2.24.0
Released June 4, 2026
The main feature for this release is an in-memory ACL cache (`auth-access-cache`) that can help bring down the read load
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
**Features:**
* Add per-visitor rate limit on new topic creations (`visitor-topic-creation-limit-burst` / `visitor-topic-creation-limit-replenish`, defaults 100 burst / 1m replenish) to mitigate topic-enumeration / squatting attacks that inflate the in-memory topic map
**Bug fixes + maintenance:**
* Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint
* Restrict the publish dialog's local file preview to safe image types (png/jpg/gif/webp) to prevent same-origin script execution from blob URLs when previewing a crafted SVG ([GHSA-j8hr-p342-xrmh](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-j8hr-p342-xrmh), thanks to [@Venukamatchi](https://github.com/Venukamatchi) for reporting)
## ntfy iOS app v1.6.0
Released May 12, 2026
**Bug fixes + maintenance:**
* Fix crash in iOS v1.5.1 ([#1736](https://github.com/binwiederhier/ntfy/issues/1736), thanks to [@russ-who](https://github.com/russ-who) for reporting and [@am7590](https://github.com/am7590) for fixing)
**Features:**
* Tap a notification to open its click URL, or copy the message text if no click URL is set; inline URLs in message text are now tappable as well ([ntfy-ios#37](https://github.com/binwiederhier/ntfy-ios/pull/37), thanks to [@am7590](https://github.com/am7590) for the contribution)
## ntfy iOS app v1.5.1
Released April 27, 2026
This release continues the iOS stability push from v1.4.1, with improved background polling reliability, better error
handling and logging, and a few small UI fixes. The minimum supported iOS version is now iOS 15.
**Features:**
* Bump minimum iOS version to iOS 15 ([ntfy-ios#36](https://github.com/binwiederhier/ntfy-ios/pull/36), thanks to [@am7590](https://github.com/am7590) for the contribution)
**Bug fixes + maintenance:**
* Improve background poll reliability by waiting for polls to finish before calling the fetch completion handler, and saving notifications on the Core Data context queue ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Make `poll_request` parsing more tolerant and surface concrete poll errors instead of failing silently ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Poll subscriptions when the subscribed topics list appears, for more reactive updates ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Fix bug where tapping "Add user" a second time would briefly open and then dismiss the add user view ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Split `SettingsView` into separate files to improve readability ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Add Firebase subscribe/unsubscribe logging to aid debugging ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution)
## ntfy server v2.22.0
Released April 21, 2026
**Bug fixes + maintenance:**
* Tighten web push endpoint allow-list regex to prevent SSRF via unanchored pattern matching ([GHSA-w9hq-5jg7-q4j7](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-w9hq-5jg7-q4j7), thanks to [@MightyNawaf](https://github.com/MightyNawaf) for reporting)
* Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing)
* Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting)
## ntfy iOS app v1.4.1
Released April 14, 2026
This is the first iOS release in 3 years, focusing on stability fixes as per the [iOS improvement plan](https://github.com/binwiederhier/ntfy/issues/1680).
**Bug fixes + maintenance:**
* Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution)
## ntfy server v2.21.0
Released March 30, 2026
This release adds the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is
required because ntfy.sh was used to send unsolicited emails and the AWS SES account was suspended. Going forward,
ntfy.sh won't be able to send emails unless the email address was verified ahead of time.
**Features:**
* Add verified email recipients feature with `smtp-sender-verify` config flag, allowing server admins to require email
address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681))
## ntfy server v2.20.1
Released March 27, 2026
This is a small bugfix release that only affects high volume S3 backends that struggle with HTTP/2.
**Bug fixes + maintenance:**
* [Attachments](config.md#attachments): Add `disable_http2=true` S3 URL option to work around HTTP/2 stream errors with DigitalOcean Spaces and other S3-compatible providers ([#1678](https://github.com/binwiederhier/ntfy/issues/1678)/[#1679](https://github.com/binwiederhier/ntfy/pull/1679))
## ntfy server v2.20.0
Released March 26, 2026
This release is another step towards making it possible to help scale ntfy up and out 🔥! With this release, you can store
attachments in an S3-compatible object store as an alterative to the directory. See [attachment store](config.md#attachments)
for details.
!!! warning
With this release, ntfy will take full control over the attachment directory or S3 bucket. Files/objects in the configured `attachment-cache-dir`
that match the message ID format (12 chars, matching `^[A-Za-z0-9]{12}$`), and have no entries in the message database will be deleted.
**Do not use a directory or S3 bucket as `attachment-cache-dir` that is also used for something else.**
This is a small behavioral change that was necessary because the old logic often left attachments behind and would not clean them
up. Unless you have re-used the attachment directory for anything else (which is hopefully never done), this should not affect
you at all.
**Features:**
* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option ([#1656](https://github.com/binwiederhier/ntfy/pull/1656)/[#1672](https://github.com/binwiederhier/ntfy/pull/1672))
**Bug fixes + maintenance:**
* Reject invalid e-mail addresses (e.g. multiple comma-separated recipients) with HTTP 400
* Add OpenRC init service file ([#1650](https://github.com/binwiederhier/ntfy/pull/1650), thanks to [@ageru](https://github.com/ageru) for the contribution)
## ntfy server v2.19.2
Released March 16, 2026
This is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to
further reduce primary load.
**Bug fixes + maintenance:**
* Fix race condition in web push subscription causing FK constraint violation when concurrent requests hit the same endpoint
* Route authorization query to read-only database replica to reduce primary database load
## ntfy server v2.19.1
Released March 15, 2026
This is a bugfix release to avoid PostgreSQL insert failures due to invalid UTF-8 messages. It also fixes `database-url`
validation incorrectly rejecting `postgresql://` connection strings.
**Bug fixes + maintenance:**
* Fix invalid UTF-8 in HTTP headers (e.g. Latin-1 encoded text) causing PostgreSQL insert failures and dropping entire message batches
* Fix `database-url` validation rejecting `postgresql://` connection strings ([#1657](https://github.com/binwiederhier/ntfy/issues/1657)/[#1658](https://github.com/binwiederhier/ntfy/pull/1658))
## ntfy server v2.19.0
Released March 15, 2026
This is a fast-follow release that enables Postgres read replica support.
To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas
using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages,
checking access permissions, etc) are distributed across the replicas using round-robin, while all writes and
correctness-critical reads continue to go to the primary. If a replica becomes unhealthy, ntfy automatically falls back
to the primary until the replica recovers.
**Features:**
* Support [PostgreSQL read replicas](config.md#postgresql) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
**Bug fixes + maintenance:**
* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting)
* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting)
## ntfy server v2.18.0
Released March 7, 2026
This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql).
The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents,
went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests,
which took lots of evenings.
I'll not instantly switch ntfy.sh over. Instead, I'm kindly asking the community to test the Postgres support and report back to me
if things are working (or not working). There is a [one-off migration tool](https://github.com/binwiederhier/ntfy/tree/main/tools/pgimport) (entirely written by AI) that you can use to migrate.
**Features:**
* Add experimental [PostgreSQL support](config.md#postgresql) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
**Bug fixes + maintenance:**
* Preserve `<br>` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting)
## ntfy Android v1.24.0
Released March 5, 2026
This is a tiny release that will revert the "reconnecting ..." behavior of the foreground notification. Lots of people
have complained about it, so I'm replacing it with a notification that shows up when the server connection has failed
for >15 minutes, hoping that people will be less annoyed by that.
**Features:**
* Show notification when connection to server has been lost for 15+ minutes, with dismiss, snooze and never-show-again actions
**Bug fixes + maintenance:**
* Fix crash in settings when fragment is detached during backup/restore or log operations
## ntfy Android v1.23.0
Released February 22, 2026
This release adds support for search within a topic, and adds [copy action](publish.md#copy-to-clipboard) support
to the Android app.
**Features:**
* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
**Bug fixes + maintenance:**
* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
## ntfy server v2.17.0
Released February 8, 2026
@@ -1698,26 +2093,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy Android v1.23.x (UNRELEASED)
### ntfy iOS app v1.8.0 (UNRELEASED)
**Features:**
* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
* 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:**
* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
* Fix crash in settings when fragment is detached during backup/restore or log operations
### ntfy server v2.12.x (UNRELEASED)
**Features:**
* Add PostgreSQL as an alternative database backend for the web push subscription store via `database-url` config option
* 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)
+853
View File
@@ -0,0 +1,853 @@
/* Config Generator */
/* Hidden utility */
.cg-hidden {
display: none !important;
}
/* Open button */
.cg-open-btn {
display: inline-block;
padding: 8px 20px;
background: var(--md-primary-fg-color);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.cg-open-btn:hover {
opacity: 0.85;
}
/* Modal overlay */
.cg-modal {
position: fixed;
inset: 0;
z-index: 1000;
}
.cg-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.cg-modal-dialog {
position: absolute;
inset: 24px;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
overflow: hidden;
font-size: 0.78rem;
}
.cg-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
}
.cg-modal-header-left {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.cg-modal-title {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
}
.cg-badge-beta {
display: inline-block;
padding: 1px 8px;
margin-left: 8px;
background: var(--md-primary-fg-color);
color: #fff;
font-size: 0.6rem;
font-weight: 600;
border-radius: 10px;
letter-spacing: 0.5px;
vertical-align: middle;
}
.cg-modal-desc {
font-size: 0.75rem;
color: #888;
}
.cg-modal-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.cg-modal-reset {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
color: #777;
cursor: pointer;
padding: 4px 12px;
font-family: inherit;
transition: color 0.15s, border-color 0.15s;
}
.cg-modal-reset:hover {
color: #333;
border-color: #999;
}
.cg-modal-close {
background: none;
border: none;
font-size: 1.4rem;
color: #999;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.cg-modal-close:hover {
color: #333;
}
/* Modal body: left + right */
.cg-modal-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Left panel */
#cg-left {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #ddd;
min-width: 0;
}
.cg-nav {
display: flex;
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
padding: 0 16px;
}
.cg-nav-tab {
padding: 9px 14px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
color: #777;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
user-select: none;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.cg-nav-tab:hover {
color: #444;
}
.cg-nav-tab.active {
color: var(--md-primary-fg-color);
border-bottom-color: var(--md-primary-fg-color);
}
.cg-panels {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.cg-panel {
display: none;
}
.cg-panel.active {
display: block;
}
.cg-panel-desc {
font-size: 0.75rem;
color: #888;
margin-bottom: 12px;
line-height: 1.5;
}
.cg-panel-desc a {
color: var(--md-primary-fg-color);
}
.cg-help {
color: var(--md-primary-fg-color);
text-decoration: none;
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
transition: color 0.15s;
}
.cg-help:hover {
color: var(--md-primary-fg-color--dark, #2a6e5f);
}
/* Right panel */
#cg-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.cg-output-tabs {
display: flex;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
padding: 0 16px;
}
.cg-output-tab {
padding: 9px 14px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
color: #777;
transition: color 0.15s, border-color 0.15s;
user-select: none;
white-space: nowrap;
}
.cg-output-tab:hover {
color: #444;
}
.cg-output-tab.active {
color: var(--md-primary-fg-color);
border-bottom-color: var(--md-primary-fg-color);
}
.cg-btn-copy {
margin-left: auto;
background: none;
color: #777;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 9px 10px;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
}
.cg-btn-copy:hover {
color: #333;
}
.cg-output-wrap {
flex: 1;
overflow: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.cg-output-wrap pre {
margin: 0;
padding: 8px 10px;
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
border-radius: 6px;
overflow-x: auto;
font-size: 0.76rem;
line-height: 1.5;
flex: 1;
white-space: pre;
}
.cg-empty-msg {
color: #888;
font-style: italic;
}
.cg-warning {
padding: 6px 10px;
margin-top: 8px;
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
border-radius: 4px;
font-size: 0.76rem;
}
/* Form fields */
.cg-field {
margin-bottom: 0;
padding: 8px 12px;
}
.cg-field:nth-child(odd) {
background: #f8f8f8;
}
.cg-field:nth-child(even) {
background: #fff;
}
.cg-field > label {
display: block;
font-weight: 500;
margin-bottom: 4px;
font-size: 0.78rem;
color: #555;
}
.cg-field input[type="text"],
.cg-field input[type="password"],
.cg-field select {
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;
}
.cg-field input[type="text"]:focus,
.cg-field input[type="password"]:focus,
.cg-field select:focus {
border-color: var(--md-primary-fg-color);
outline: none;
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
}
.cg-checkbox {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.cg-checkbox input[type="checkbox"] {
accent-color: var(--md-primary-fg-color);
}
.cg-checkbox label {
font-weight: 500;
font-size: 0.78rem;
margin: 0;
cursor: pointer;
}
.cg-radio-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.cg-radio-group label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
font-size: 0.78rem;
cursor: pointer;
}
.cg-radio-group input[type="radio"] {
accent-color: var(--md-primary-fg-color);
}
/* Inline field: label + control side by side */
.cg-inline-field {
display: flex;
align-items: center;
gap: 12px;
}
.cg-inline-field > label {
margin-bottom: 0;
width: 60%;
flex-shrink: 0;
}
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
width: 50%;
}
.cg-inline-field > input[type="text"],
.cg-inline-field > select {
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
font-family: inherit;
box-sizing: border-box;
line-height: 1.4;
background: #fff;
}
.cg-inline-field > input[type="text"]:focus,
.cg-inline-field > select:focus {
border-color: var(--md-primary-fg-color);
outline: none;
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
}
#cg-email-in-section {
margin-top: 20px;
}
.cg-pg-label {
font-size: 0.75rem;
color: #888;
font-style: italic;
}
/* Button group toggle */
.cg-btn-group {
display: flex;
flex-shrink: 0;
}
.cg-btn-group label {
cursor: pointer;
margin: 0;
}
.cg-btn-group input[type="radio"] {
display: none;
}
.cg-btn-group span {
display: block;
padding: 4px 14px;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.4;
border: 1px solid #ccc;
color: #555;
background: #fff;
transition: background 0.15s, color 0.15s, border-color 0.15s;
user-select: none;
}
.cg-btn-group label:first-child span {
border-radius: 4px 0 0 4px;
}
.cg-btn-group label:last-child span {
border-radius: 0 4px 4px 0;
}
.cg-btn-group label + label span {
margin-left: -1px;
}
.cg-btn-group input[type="radio"]:checked + span {
background: var(--md-primary-fg-color);
color: #fff;
border-color: var(--md-primary-fg-color);
position: relative;
z-index: 1;
}
.cg-feature-grid {
display: flex;
flex-direction: column;
gap: 5px;
}
.cg-feature-row {
display: flex;
align-items: center;
gap: 8px;
}
.cg-feature-grid label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
cursor: pointer;
}
.cg-feature-grid input[type="checkbox"] {
accent-color: var(--md-primary-fg-color);
}
.cg-btn-configure {
background: none;
border: 1px solid var(--md-primary-fg-color);
border-radius: 10px;
color: var(--md-primary-fg-color);
font-size: 0.68rem;
font-family: inherit;
padding: 1px 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.cg-btn-configure:hover {
background: var(--md-primary-fg-color);
color: #fff;
}
/* Repeatable rows */
.cg-repeatable-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
flex-wrap: wrap;
}
.cg-repeatable-row input,
.cg-repeatable-row select {
flex: 1;
min-width: 80px;
padding: 5px 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
font-family: inherit;
box-sizing: border-box;
}
.cg-repeatable-row input:focus,
.cg-repeatable-row select:focus {
border-color: var(--md-primary-fg-color);
outline: none;
}
.cg-repeatable-row input:disabled,
.cg-repeatable-row select:disabled {
background: #eee;
color: #999;
cursor: not-allowed;
}
.cg-btn-remove {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
padding: 5px 8px;
color: #999;
line-height: 1;
}
.cg-btn-remove:hover {
background: #fee;
border-color: #c66;
color: #c33;
}
.cg-btn-add {
background: none;
border: 1px dashed #bbb;
border-radius: 4px;
cursor: pointer;
padding: 5px 10px;
font-size: 0.75rem;
color: #777;
margin-top: 2px;
}
.cg-btn-add:hover {
border-color: var(--md-primary-fg-color);
color: var(--md-primary-fg-color);
}
/* Dark mode */
body[data-md-color-scheme="slate"] .cg-modal-dialog {
background: #1e1e2e;
}
body[data-md-color-scheme="slate"] .cg-modal-header {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-modal-title {
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-modal-desc {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-modal-reset {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-modal-reset:hover {
border-color: #888;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-modal-close {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-modal-close:hover {
color: #ddd;
}
body[data-md-color-scheme="slate"] #cg-left {
border-right-color: #444;
}
body[data-md-color-scheme="slate"] .cg-nav {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-nav-tab {
color: #888;
}
body[data-md-color-scheme="slate"] .cg-nav-tab:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .cg-output-tabs {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-output-tab {
color: #888;
}
body[data-md-color-scheme="slate"] .cg-output-tab:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .cg-btn-copy {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-btn-copy:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .cg-output-wrap pre {
background: #161620;
color: #ddd;
border-color: #444;
}
body[data-md-color-scheme="slate"] .cg-field:nth-child(odd) {
background: #232334;
}
body[data-md-color-scheme="slate"] .cg-field:nth-child(even) {
background: #1e1e2e;
}
body[data-md-color-scheme="slate"] .cg-panel-desc {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-field > label {
color: #aaa;
}
body[data-md-color-scheme="slate"] .cg-field input[type="text"],
body[data-md-color-scheme="slate"] .cg-field input[type="password"],
body[data-md-color-scheme="slate"] .cg-field select {
background: #2a2a3a;
border-color: #555;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-btn-group span {
background: #2a2a3a;
border-color: #555;
color: #aaa;
}
body[data-md-color-scheme="slate"] .cg-btn-group input[type="radio"]:checked + span {
background: var(--md-primary-fg-color);
color: #fff;
border-color: var(--md-primary-fg-color);
}
body[data-md-color-scheme="slate"] .cg-checkbox label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-radio-group label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-feature-grid label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-repeatable-row input,
body[data-md-color-scheme="slate"] .cg-repeatable-row select {
background: #2a2a3a;
border-color: #555;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-repeatable-row input:disabled,
body[data-md-color-scheme="slate"] .cg-repeatable-row select:disabled {
background: #1a1a28;
color: #666;
}
body[data-md-color-scheme="slate"] .cg-btn-remove {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-btn-add {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-warning {
background: #3a2e00;
color: #ffc107;
border-color: #665200;
}
/* Mobile toggle bar (hidden on desktop) */
.cg-mobile-toggle {
display: none;
}
/* Responsive */
@media (max-width: 900px) {
.cg-modal-dialog {
inset: 0;
border-radius: 0;
}
.cg-modal-header {
padding: 8px 16px;
}
.cg-modal-title {
font-size: 0.85rem;
}
.cg-modal-desc {
display: none;
}
.cg-modal-body {
flex-direction: column;
}
.cg-mobile-toggle {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid #ddd;
}
.cg-mobile-toggle-btn {
flex: 1;
padding: 8px 0;
border: none;
background: #f5f5f5;
font-size: 0.78rem;
font-weight: 500;
font-family: inherit;
color: #777;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.cg-mobile-toggle-btn.active {
background: #fff;
color: var(--md-primary-fg-color);
box-shadow: inset 0 -2px 0 var(--md-primary-fg-color);
}
#cg-left {
border-right: none;
flex: 1;
min-height: 0;
}
#cg-right {
flex: 1;
display: none;
min-height: 0;
}
#cg-right.cg-mobile-active {
display: flex;
}
#cg-left.cg-mobile-hidden {
display: none;
}
.cg-nav {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
}
.cg-inline-field {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.cg-inline-field > label {
width: 100%;
}
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
width: 100%;
}
}
/* Dark mode mobile toggle */
body[data-md-color-scheme="slate"] .cg-mobile-toggle {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn {
background: #2a2a3a;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn.active {
background: #1e1e2e;
color: var(--md-primary-fg-color);
}
+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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

+1220
View File
File diff suppressed because it is too large Load Diff
+1363
View File
File diff suppressed because it is too large Load Diff
+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();
}
})();
+31 -1
View File
@@ -245,6 +245,9 @@ combined with `since=` (defaults to `since=all`).
curl -s "ntfy.sh/mytopic/json?poll=1"
```
Note that a poll without `since=` returns a topic's **entire cache**, which on a busy topic can be
large. See [replay limits](#replay-limits) below.
### Fetch cached messages
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
@@ -275,6 +278,28 @@ parameter (makes most sense with the `poll=1` parameter):
curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
```
### Replay limits
Reading cached messages (a `poll=1` request, or any request with `since=`) replays messages the server
already stored, so unlike a live subscription its cost grows with the size of the topic's cache. Two
server-side limits apply, both of which a well-behaved client should handle:
* **The response is size-capped.** A replay returns only the newest messages that fit in 10 MB
**per topic** (counting body, title, tags and every other publisher-set field), and a capped response carries an `X-Messages-Truncated: 1` header. If you see that
header, older messages were dropped and you did not receive the full cache. In practice this only
affects very large topics; a client polling with `since=` never comes close.
* **Replayed bytes count against your daily bandwidth budget**, the same one attachment downloads use
(see [limitations](../publish.md#limitations)). Exceeding it returns `HTTP 429` with ntfy error code
`42905`, and no messages are written.
Both limits exist because a poll without `since=` re-reads the whole cache every time. If you are
polling repeatedly, **pass `since=<last message ID>`** rather than re-fetching everything. The limits
still apply to a `since=` replay, but it returns only what is new, so in practice you will not come
near either one:
```
curl -s "ntfy.sh/mytopic/json?poll=1&since=nFS3knfcQ1xe"
```
### Filter messages
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
@@ -308,6 +333,11 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
```
When replaying cached messages for several topics at once, they are ordered by their `time` field.
Because `time` has **second granularity**, messages published within the same second share a sort key:
each topic's own messages stay in publish order, but the interleaving *between* topics is not defined.
If you need a total order across topics, sort by `time` and fall back to the order received.
### Authentication
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
@@ -427,7 +457,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
| Parameter | Aliases (case-insensitive) | Description |
|-------------|----------------------------|---------------------------------------------------------------------------------|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
| `poll` | `X-Poll`, `po` | Return cached messages and close connection (see [replay limits](#replay-limits)) |
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
+53 -54
View File
@@ -1,25 +1,25 @@
module heckel.io/ntfy/v2
go 1.24.6
go 1.25.8
require (
cloud.google.com/go/firestore v1.21.0 // indirect
cloud.google.com/go/storage v1.59.2 // indirect
cloud.google.com/go/firestore v1.25.0 // indirect
cloud.google.com/go/storage v1.65.1 // 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.18.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/emersion/go-smtp v0.25.0
github.com/gabriel-vasile/mimetype v1.4.15
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.33
github.com/mattn/go-sqlite3 v1.14.50
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.47.0
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0
golang.org/x/term v0.39.0
golang.org/x/time v0.14.0
google.golang.org/api v0.265.0
golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
google.golang.org/api v0.294.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -28,79 +28,78 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.19.0
firebase.google.com/go/v4 v4.21.0
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.8.0
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/prometheus/client_golang v1.24.1
github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.40.0
golang.org/x/text v0.33.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.41.0
)
require (
cel.dev/expr v0.25.1 // indirect
cel.dev/expr v0.25.3 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth v0.23.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/longrunning v0.8.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
cloud.google.com/go/iam v1.13.0 // indirect
cloud.google.com/go/longrunning v1.2.0 // indirect
cloud.google.com/go/monitoring v1.30.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.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
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.39.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // 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.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
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.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.21 // indirect
github.com/googleapis/gax-go/v2 v2.24.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
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/procfs v0.19.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.3 // 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.40.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.49.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.46.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/otel/sdk v1.46.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+119 -118
View File
@@ -1,41 +1,41 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo=
cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
cloud.google.com/go/storage v1.59.2 h1:gmOAuG1opU8YvycMNpP+DvHfT9BfzzK5Cy+arP+Nocw=
cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
cloud.google.com/go/firestore v1.25.0 h1:yY3rQKyQXNhnhETdseNayF6W1p4x0bdg9ZYS4hKJfOw=
cloud.google.com/go/firestore v1.25.0/go.mod h1:0PU6hj+r/QlhB6BLsRX+Kt/SYefTXrpYrBeHbYaSis8=
cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U=
cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0=
cloud.google.com/go/logging v1.19.1 h1:7SsLhyTDBDrJw+Ll6Ns3I2mByqHXvJUc3rGjSlwiWgU=
cloud.google.com/go/logging v1.19.1/go.mod h1:2IkQ/d8jVJqV2qW8ZUGUiMjdZG1gkLD2JReGbZ8isqg=
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
cloud.google.com/go/storage v1.65.1 h1:LRRpBJUTf+OXDPX9jZUKZ3mSLIsz3htG+qUpeNZovyA=
cloud.google.com/go/storage v1.65.1/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM=
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.21.0 h1:HBZV4jrLtFYj8EwWyqEZOuRLfkfkV2bpnfyyXHOhPxY=
firebase.google.com/go/v4 v4.21.0/go.mod h1:CDumIdA5oTiyDpLNVcQoW8ZrB5CTgyE2D45DuENIABg=
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.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 h1:3SdxXLkgAfiHRWcGTq6fneq9jgoJzneiY0yPQnjoT2E=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0/go.mod h1:1iIdl0k+ppn9wT0wzR9H7HkSvIui/4qgtnKW10cQtds=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0/go.mod h1:SRAbhyZ4R4FagHMM9VtRgSY/lheRoht2fKelZXQUenk=
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=
@@ -50,31 +50,30 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBMEtI=
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.39.0 h1:1uwRDYPYG8BIBU9Mj1sUAebNmlM6beu/ZKKweSLDxk8=
github.com/envoyproxy/go-control-plane/envoy v1.39.0/go.mod h1:5e4ylfTZO723MEEFsCpSW4ZEBWR8mwkEyXfwJBTCZ9c=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -96,10 +95,10 @@ 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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/googleapis/enterprise-certificate-proxy v0.3.21 h1:OFdQ3tnCX/zaQ0Cedur3D3z7kI6HiLX9g3TiAN4/DFU=
github.com/googleapis/enterprise-certificate-proxy v0.3.21/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg=
github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -108,20 +107,20 @@ 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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
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=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/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=
@@ -133,29 +132,27 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
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/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spiffe/go-spiffe/v2 v2.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=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
@@ -165,36 +162,40 @@ 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.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/contrib/detectors/gcp v1.46.0 h1:PI8dGkqDaQkwJ8kOopqMhDTbrnK3UIeG/RCHH4HErbo=
go.opentelemetry.io/contrib/detectors/gcp v1.46.0/go.mod h1:nsrN5c/sOLoY2vsPxN/rQ0V0nvGrWJCqcW4UXLtqNG8=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
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.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
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,10 +210,10 @@ 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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
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=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -220,8 +221,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.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 +235,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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.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 +246,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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
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,10 +259,10 @@ 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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -270,24 +271,24 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
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.294.0 h1:8gASjJxdtcIieB3OqbkLcF0FfbXVNqKtU5iozD1ssvA=
google.golang.org/api v0.294.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4=
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-20260203192932-546029d2fa20 h1:/CU1zrxTpGylJJbe3Ru94yy6sZRbzALq2/oxl3pGB3U=
google.golang.org/genproto v0.0.0-20260203192932-546029d2fa20/go.mod h1:Tt+08/KdKEt3l8x3Pby3HLQxMB3uk/MzaQ4ZIv0ORTs=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 h1:jPP56YzdY899KJ5W7efXHt/CkjlVfAaoFOwdi/IEAFA=
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5/go.mod h1:gutZdP0DwAHp4vu5WaXgEK7tjsJ77ZEqzlOFWGZGziE=
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU=
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+14 -76
View File
@@ -1,82 +1,32 @@
package server
package mail
import (
_ "embed" // required by go:embed
"encoding/json"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
type mailer interface {
Send(v *visitor, m *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
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 *message, to string) error {
return s.withCount(v, m, func() error {
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
if err != nil {
return err
}
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m)
if err != nil {
return err
}
var auth smtp.Auth
if s.config.SMTPSenderUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
}
ev := logvm(v, m).
Tag(tagEmail).
Fields(log.Context{
"email_via": s.config.SMTPSenderAddr,
"email_user": s.config.SMTPSenderUser,
"email_to": to,
})
if ev.IsTrace() {
ev.Field("email_body", message).Trace("Sending email")
} else if ev.IsDebug() {
ev.Debug("Sending email")
}
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
})
}
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 *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 *message) (string, error) {
func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) {
topicURL := baseURL + "/" + m.Topic
subject := m.Title
if subject == "" {
@@ -86,10 +36,7 @@ func formatMail(baseURL, senderIP, from, to string, m *message) (string, error)
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
}
@@ -134,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,12 +1,14 @@
package server
package mail
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/model"
)
func TestFormatMail_Basic(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
ID: "abc",
Time: 1640382204,
Event: "message",
@@ -27,7 +29,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
}
func TestFormatMail_JustEmojis(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
ID: "abc",
Time: 1640382204,
Event: "message",
@@ -49,7 +51,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
}
func TestFormatMail_JustOtherTags(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
ID: "abc",
Time: 1640382204,
Event: "message",
@@ -73,7 +75,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
}
func TestFormatMail_JustPriority(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
ID: "abc",
Time: 1640382204,
Event: "message",
@@ -97,7 +99,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
}
func TestFormatMail_UTF8Subject(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
ID: "abc",
Time: 1640382204,
Event: "message",
@@ -119,7 +121,7 @@ This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://nt
}
func TestFormatMail_WithAllTheThings(t *testing.T) {
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &model.Message{
ID: "abc",
Time: 1640382204,
Event: "message",
+140
View File
@@ -0,0 +1,140 @@
package mail
import (
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
)
const (
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 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
}
// 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 {
return &realSender{config: config}
}
// 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
}
log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email")
err = s.sendRaw(to, []byte(message))
s.count(err == nil)
return err
}
// 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}>
To: {to}
Date: {date}
Subject: {subject}
Content-Type: text/plain; charset="utf-8"
{body}`
message = strings.ReplaceAll(message, "{from}", s.config.From)
message = strings.ReplaceAll(message, "{to}", to)
message = strings.ReplaceAll(message, "{date}", date)
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
message = strings.ReplaceAll(message, "{body}", body)
log.Tag(tagMail).Field("email_to", to).Debug("Sending email")
return s.sendRaw(to, []byte(message))
}
// 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
}
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)
}
func (s *realSender) count(ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
if ok {
s.success++
} else {
s.failure++
}
}
+618
View File
@@ -0,0 +1,618 @@
package message
import (
"database/sql"
"encoding/json"
"errors"
"net/netip"
"slices"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
const (
tagMessageCache = "message_cache"
schemaStore = "message" // Store name in the schema_version table (see db/schema)
// NoLimit reads a topic's cached messages without a size budget.
NoLimit = 0
)
var errNoRows = errors.New("no rows found")
// queries holds the database-specific SQL queries
type queries struct {
insertMessage string
selectScheduledMessageIDsBySeqID string
deleteScheduledBySequenceID string
updateMessagesForTopicExpiry string
selectMessagesByID string
selectMessagesSinceTime string
selectMessagesSinceTimeScheduled string
selectMessagesSinceID string
selectMessagesSinceIDScheduled string
selectMessagesLatest string
selectMessagesDue string
deleteExpiredMessages string
updateMessagePublished string
selectMessagesCount string
selectTopics string
markExpiredAttachmentsDeleted string
selectAttachmentsSizeBySender string
selectAttachmentsSizeByUserID string
selectAttachmentsWithSizes string
selectStats string
updateStats string
updateMessageTime string
}
// Cache stores published messages
type Cache struct {
db *db.DB
queue *util.BatchingQueue[*model.Message]
nop bool
mu *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer)
queries queries
}
func newCache(db *db.DB, queries queries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *Cache {
var queue *util.BatchingQueue[*model.Message]
if batchSize > 0 || batchTimeout > 0 {
queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout)
}
c := &Cache{
db: db,
queue: queue,
nop: nop,
mu: mu,
queries: queries,
}
go c.processMessageBatches()
return c
}
func (c *Cache) maybeLock() {
if c.mu != nil {
c.mu.Lock()
}
}
func (c *Cache) maybeUnlock() {
if c.mu != nil {
c.mu.Unlock()
}
}
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asynchronously.
// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor.
func (c *Cache) AddMessage(m *model.Message) error {
if c.queue != nil {
c.queue.Enqueue(m)
return nil
}
return c.addMessages([]*model.Message{m})
}
// AddMessages synchronously stores a batch of messages to the message cache
func (c *Cache) AddMessages(ms []*model.Message) error {
return c.addMessages(ms)
}
func (c *Cache) addMessages(ms []*model.Message) error {
c.maybeLock()
defer c.maybeUnlock()
if c.nop {
return nil
}
if len(ms) == 0 {
return nil
}
start := time.Now()
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(c.queries.insertMessage)
if err != nil {
return err
}
defer stmt.Close()
for _, m := range ms {
if m.Event != model.MessageEvent && m.Event != model.MessageDeleteEvent && m.Event != model.MessageClearEvent {
return model.ErrUnexpectedMessageType
}
published := m.Time <= time.Now().Unix()
tags := util.SanitizeUTF8(strings.Join(m.Tags, ","))
var attachmentName, attachmentType, attachmentURL string
var attachmentSize, attachmentExpires int64
var attachmentDeleted bool
if m.Attachment != nil {
attachmentName = util.SanitizeUTF8(m.Attachment.Name)
attachmentType = util.SanitizeUTF8(m.Attachment.Type)
attachmentSize = m.Attachment.Size
attachmentExpires = m.Attachment.Expires
attachmentURL = util.SanitizeUTF8(m.Attachment.URL)
}
var actionsStr string
if len(m.Actions) > 0 {
actionsBytes, err := json.Marshal(m.Actions)
if err != nil {
return err
}
actionsStr = string(actionsBytes)
}
var sender string
if m.Sender.IsValid() {
sender = m.Sender.String()
}
_, err := stmt.Exec(
m.ID,
m.SequenceID,
m.Time,
m.Event,
m.Expires,
util.SanitizeUTF8(m.Topic),
util.SanitizeUTF8(m.Message),
util.SanitizeUTF8(m.Title),
m.Priority,
tags,
util.SanitizeUTF8(m.Click),
util.SanitizeUTF8(m.Icon),
actionsStr,
attachmentName,
attachmentType,
attachmentSize,
attachmentExpires,
attachmentURL,
attachmentDeleted, // Always zero
sender,
m.User,
util.SanitizeUTF8(m.ContentType),
m.Encoding,
published,
)
if err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
return err
}
log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start))
return nil
}
// Messages returns all cached messages for a topic, oldest first. Prefer MessagesCapped on
// request paths: an uncapped replay of a busy topic is as large as the topic's entire cache.
func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
messages, _, err := c.MessagesCapped(topic, since, scheduled, NoLimit)
return messages, err
}
// MessagesCapped returns cached messages for a topic, oldest first, keeping the newest messages
// that fit in maxBytes worth of Message.Size (0 = no budget). The bool reports whether older messages
// were dropped, so the caller can tell the client that what it got is incomplete.
func (c *Cache) MessagesCapped(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
if since.IsNone() {
return make([]*model.Message, 0), false, nil
} else if since.IsLatest() {
messages, err := c.messagesLatest(topic)
return messages, false, err
} else if since.IsID() {
return c.messagesSinceID(topic, since, scheduled, maxBytes)
}
return c.messagesSinceTime(topic, since, scheduled, maxBytes)
}
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
var rows *sql.Rows
var err error
rdb := c.db.ReadOnly()
if scheduled {
rows, err = rdb.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())
} else {
rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
}
if err != nil {
return nil, false, err
}
return readMessagesCapped(rows, maxBytes)
}
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
var rows *sql.Rows
var err error
rdb := c.db.ReadOnly()
if scheduled {
rows, err = rdb.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID())
} else {
rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())
}
if err != nil {
return nil, false, err
}
return readMessagesCapped(rows, maxBytes)
}
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesLatest, topic)
if err != nil {
return nil, err
}
return readMessages(rows)
}
// MessagesDue returns all messages that are due for publishing
func (c *Cache) MessagesDue() ([]*model.Message, error) {
rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix())
if err != nil {
return nil, err
}
return readMessages(rows)
}
// DeleteExpiredMessages deletes up to `limit` expired messages in a single query
// and returns the number of deleted rows.
func (c *Cache) DeleteExpiredMessages(limit int) (int64, error) {
c.maybeLock()
defer c.maybeUnlock()
result, err := c.db.Exec(c.queries.deleteExpiredMessages, time.Now().Unix(), limit)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// Message returns the message with the given ID, or ErrMessageNotFound if not found
func (c *Cache) Message(id string) (*model.Message, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesByID, id)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, model.ErrMessageNotFound
}
return readMessage(rows)
}
// UpdateMessageTime updates the time column for a message by ID. This is only used for testing.
func (c *Cache) UpdateMessageTime(messageID string, timestamp int64) error {
c.maybeLock()
defer c.maybeUnlock()
_, err := c.db.Exec(c.queries.updateMessageTime, timestamp, messageID)
return err
}
// MarkPublished marks a message as published
func (c *Cache) MarkPublished(m *model.Message) error {
c.maybeLock()
defer c.maybeUnlock()
_, err := c.db.Exec(c.queries.updateMessagePublished, m.ID)
return err
}
// MessagesCount returns the total number of messages in the cache. On Postgres, this is the
// planner's estimate once the table has been analyzed, not an exact count.
func (c *Cache) MessagesCount() (int, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesCount)
if err != nil {
return 0, err
}
defer rows.Close()
if !rows.Next() {
return 0, errNoRows
}
var count int
if err := rows.Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// Topics returns a list of all topics with messages in the cache
func (c *Cache) Topics() ([]string, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectTopics)
if err != nil {
return nil, err
}
defer rows.Close()
return readStrings(rows)
}
// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
func (c *Cache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
c.maybeLock()
defer c.maybeUnlock()
return db.QueryTx(c.db, func(tx *sql.Tx) ([]string, error) {
rows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID)
if err != nil {
return nil, err
}
defer rows.Close()
ids, err := readStrings(rows)
if err != nil {
return nil, err
}
rows.Close() // Close rows before executing delete in same transaction
if _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil {
return nil, err
}
return ids, nil
})
}
// ExpireMessages marks messages in the given topics as expired
func (c *Cache) ExpireMessages(topics ...string) error {
c.maybeLock()
defer c.maybeUnlock()
return db.ExecTx(c.db, func(tx *sql.Tx) error {
for _, t := range topics {
if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil {
return err
}
}
return nil
})
}
// MarkExpiredAttachmentsDeleted marks up to `limit` expired attachments as deleted in a single
// query and returns the number of updated rows.
func (c *Cache) MarkExpiredAttachmentsDeleted(limit int) (int64, error) {
c.maybeLock()
defer c.maybeUnlock()
result, err := c.db.Exec(c.queries.markExpiredAttachmentsDeleted, time.Now().Unix(), limit)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender
func (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix())
if err != nil {
return 0, err
}
return c.readAttachmentBytesUsed(rows)
}
// AttachmentBytesUsedByUser returns the total size of active attachments for the given user
func (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())
if err != nil {
return 0, err
}
return c.readAttachmentBytesUsed(rows)
}
// AttachmentsWithSizes returns a map of message ID to attachment size for all active
// (non-expired, non-deleted) attachments. This is used to hydrate the attachment store's
// size tracking on startup and during periodic sync.
func (c *Cache) AttachmentsWithSizes() (map[string]int64, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsWithSizes, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
attachments := make(map[string]int64)
for rows.Next() {
var id string
var size int64
if err := rows.Scan(&id, &size); err != nil {
return nil, err
}
attachments[id] = size
}
if err := rows.Err(); err != nil {
return nil, err
}
return attachments, nil
}
func (c *Cache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
defer rows.Close()
var size int64
if !rows.Next() {
return 0, errors.New("no rows found")
}
if err := rows.Scan(&size); err != nil {
return 0, err
} else if err := rows.Err(); err != nil {
return 0, err
}
return size, nil
}
// UpdateStats updates the total message count statistic
func (c *Cache) UpdateStats(messages int64) error {
c.maybeLock()
defer c.maybeUnlock()
_, err := c.db.Exec(c.queries.updateStats, messages)
return err
}
// Stats returns the total message count statistic
func (c *Cache) Stats() (messages int64, err error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectStats)
if err != nil {
return 0, err
}
defer rows.Close()
if !rows.Next() {
return 0, errNoRows
}
if err := rows.Scan(&messages); err != nil {
return 0, err
}
return messages, nil
}
// Close closes the underlying database connection
func (c *Cache) Close() error {
return c.db.Close()
}
func (c *Cache) processMessageBatches() {
if c.queue == nil {
return
}
for messages := range c.queue.Dequeue() {
if err := c.addMessages(messages); err != nil {
log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
}
}
}
// readMessagesCapped reads a newest-first result set, keeping the newest messages that fit in
// maxBytes worth of Message.Size (0 = no budget), and reverses them into the oldest-first
// order callers expect. It stops scanning once the budget is spent rather than reading everything
// and trimming, so a replay of a huge topic never materializes the whole cache. The bool reports
// whether older messages were left behind.
func readMessagesCapped(rows *sql.Rows, maxBytes int64) ([]*model.Message, bool, error) {
defer rows.Close()
messages := make([]*model.Message, 0)
truncated := false
var total int64
for rows.Next() {
m, err := readMessage(rows)
if err != nil {
return nil, false, err
}
if maxBytes > 0 {
size := int64(m.Size())
// Always return at least one message, even if it alone exceeds the budget: an empty
// reply is less useful than an oversized one, and the per-field limits bound how big it gets.
if len(messages) > 0 && total+size > maxBytes {
truncated = true
break
}
total += size
}
messages = append(messages, m)
}
if !truncated {
if err := rows.Err(); err != nil {
return nil, false, err
}
}
slices.Reverse(messages)
return messages, truncated, nil
}
func readMessages(rows *sql.Rows) ([]*model.Message, error) {
defer rows.Close()
messages := make([]*model.Message, 0)
for rows.Next() {
m, err := readMessage(rows)
if err != nil {
return nil, err
}
messages = append(messages, m)
}
if err := rows.Err(); err != nil {
return nil, err
}
return messages, nil
}
func readMessage(rows *sql.Rows) (*model.Message, error) {
var timestamp, expires, attachmentSize, attachmentExpires int64
var priority int
var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
err := rows.Scan(
&id,
&sequenceID,
&timestamp,
&event,
&expires,
&topic,
&msg,
&title,
&priority,
&tagsStr,
&click,
&icon,
&actionsStr,
&attachmentName,
&attachmentType,
&attachmentSize,
&attachmentExpires,
&attachmentURL,
&sender,
&user,
&contentType,
&encoding,
)
if err != nil {
return nil, err
}
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
var actions []*model.Action
if actionsStr != "" {
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
return nil, err
}
}
senderIP, err := netip.ParseAddr(sender)
if err != nil {
senderIP = netip.Addr{} // if no IP stored in database, return invalid address
}
var att *model.Attachment
if attachmentName != "" && attachmentURL != "" {
att = &model.Attachment{
Name: attachmentName,
Type: attachmentType,
Size: attachmentSize,
Expires: attachmentExpires,
URL: attachmentURL,
}
}
return &model.Message{
ID: id,
SequenceID: sequenceID,
Time: timestamp,
Expires: expires,
Event: event,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
Click: click,
Icon: icon,
Actions: actions,
Attachment: att,
Sender: senderIP,
User: user,
ContentType: contentType,
Encoding: encoding,
}, nil
}
func readStrings(rows *sql.Rows) ([]string, error) {
strs := make([]string, 0)
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
strs = append(strs, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
return strs, nil
}
+115
View File
@@ -0,0 +1,115 @@
package message
import (
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
)
// PostgreSQL runtime query constants
const (
postgresInsertMessageQuery = `
INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
`
postgresSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`
postgresDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`
postgresUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2`
postgresSelectMessagesByIDQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE mid = $1
`
postgresSelectMessagesSinceTimeQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE topic = $1 AND time >= $2 AND published = TRUE
ORDER BY time DESC, id DESC
`
postgresSelectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE topic = $1 AND time >= $2
ORDER BY time DESC, id DESC
`
postgresSelectMessagesSinceIDQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE topic = $1
AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0)
AND published = TRUE
ORDER BY time DESC, id DESC
`
postgresSelectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE topic = $1
AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE)
ORDER BY time DESC, id DESC
`
postgresSelectMessagesLatestQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE topic = $1 AND published = TRUE
ORDER BY time DESC, id DESC
LIMIT 1
`
postgresSelectMessagesDueQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message
WHERE time <= $1 AND published = FALSE
ORDER BY time, id
`
postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
// Planner estimate, since a COUNT(*) scans the whole table; reltuples is -1 if never analyzed
postgresSelectMessagesCountQuery = `
SELECT CASE WHEN reltuples < 0 THEN (SELECT COUNT(*) FROM message) ELSE reltuples::BIGINT END
FROM pg_class
WHERE oid = 'message'::regclass
`
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
postgresDeleteExpiredMessagesQuery = `DELETE FROM message WHERE mid IN (SELECT mid FROM message WHERE expires <= $1 AND published = TRUE LIMIT $2)`
postgresMarkExpiredAttachmentsDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid IN (SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE LIMIT $2)`
postgresSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2`
postgresSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2`
postgresSelectAttachmentsWithSizesQuery = `SELECT mid, attachment_size FROM message WHERE attachment_expires > $1 AND attachment_deleted = FALSE`
postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'`
postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'`
postgresUpdateMessageTimeQuery = `UPDATE message SET time = $1 WHERE mid = $2`
)
var postgresQueries = queries{
insertMessage: postgresInsertMessageQuery,
selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery,
deleteScheduledBySequenceID: postgresDeleteScheduledBySequenceIDQuery,
updateMessagesForTopicExpiry: postgresUpdateMessagesForTopicExpiryQuery,
selectMessagesByID: postgresSelectMessagesByIDQuery,
selectMessagesSinceTime: postgresSelectMessagesSinceTimeQuery,
selectMessagesSinceTimeScheduled: postgresSelectMessagesSinceTimeIncludeScheduledQuery,
selectMessagesSinceID: postgresSelectMessagesSinceIDQuery,
selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery,
selectMessagesLatest: postgresSelectMessagesLatestQuery,
selectMessagesDue: postgresSelectMessagesDueQuery,
deleteExpiredMessages: postgresDeleteExpiredMessagesQuery,
updateMessagePublished: postgresUpdateMessagePublishedQuery,
selectMessagesCount: postgresSelectMessagesCountQuery,
selectTopics: postgresSelectTopicsQuery,
markExpiredAttachmentsDeleted: postgresMarkExpiredAttachmentsDeletedQuery,
selectAttachmentsSizeBySender: postgresSelectAttachmentsSizeBySenderQuery,
selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery,
selectAttachmentsWithSizes: postgresSelectAttachmentsWithSizesQuery,
selectStats: postgresSelectStatsQuery,
updateStats: postgresUpdateStatsQuery,
updateMessageTime: postgresUpdateMessageTimeQuery,
}
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
return nil, err
}
return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil
}
+69
View File
@@ -0,0 +1,69 @@
package message
import (
"heckel.io/ntfy/v2/db/schema"
)
// Initial PostgreSQL schema
const (
postgresCurrentSchemaVersion = 15
postgresCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS message (
id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time BIGINT NOT NULL,
event TEXT NOT NULL,
expires BIGINT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size BIGINT NOT NULL,
attachment_expires BIGINT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
sender TEXT NOT NULL,
user_id TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_message_mid ON message (mid);
CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id);
CREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message (topic, published, time, id);
CREATE INDEX IF NOT EXISTS idx_message_published_expires ON message (published, expires);
CREATE INDEX IF NOT EXISTS idx_message_attachment_expires ON message (attachment_expires) WHERE attachment_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
CREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
CREATE TABLE IF NOT EXISTS message_stats (
key TEXT PRIMARY KEY,
value BIGINT
);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
`
)
// PostgreSQL schema migrations
const (
// 14 -> 15
postgresMigrate14To15CreateIndexQuery = `
CREATE INDEX IF NOT EXISTS idx_message_attachment_expires ON message (attachment_expires) WHERE attachment_deleted = FALSE;
`
)
var (
postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQuery)
// postgresMigrations maps a schema version to the migration upgrading it to the next
// version. Always append migrations at the end, never insert in the middle.
postgresMigrations = map[int]schema.MigrateFunc{
14: schema.AsMigrateFunc(postgresMigrate14To15CreateIndexQuery),
}
)
+101
View File
@@ -0,0 +1,101 @@
package message_test
import (
"testing"
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func TestPostgresStore_Migration_From14(t *testing.T) {
// A pre-framework database at version 14: full v14 schema, version tracked in the
// hand-rolled schema_version table, and no idx_message_attachment_expires yet
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(`
CREATE TABLE message (
id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time BIGINT NOT NULL,
event TEXT NOT NULL,
expires BIGINT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size BIGINT NOT NULL,
attachment_expires BIGINT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
sender TEXT NOT NULL,
user_id TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_message_mid ON message (mid);
CREATE INDEX idx_message_sequence_id ON message (sequence_id);
CREATE INDEX idx_message_topic_published_time ON message (topic, published, time, id);
CREATE INDEX idx_message_published_expires ON message (published, expires);
CREATE INDEX idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
CREATE INDEX idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
CREATE TABLE message_stats (key TEXT PRIMARY KEY, value BIGINT);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('message', 14);
`)
require.Nil(t, err)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
// The 14 -> 15 step ran: version bumped, partial index created
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'message'`).Scan(&version))
require.Equal(t, 15, version)
var indexCount int
require.Nil(t, testDB.QueryRow(`SELECT COUNT(*) FROM pg_indexes WHERE indexname = 'idx_message_attachment_expires' AND schemaname = current_schema()`).Scan(&indexCount))
require.Equal(t, 1, indexCount)
// And the store works
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "hi there")))
messages, err := store.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Len(t, messages, 1)
// The migrated database must be structurally identical to a freshly created one
freshDB := dbtest.CreateTestPostgres(t)
_, err = message.NewPostgresStore(freshDB, 0, 0)
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
}
func TestPostgresStore_MessagesCount_UsesPlannerEstimate(t *testing.T) {
// The manager calls MessagesCount every minute for a metric; a COUNT(*) scans the whole
// table on every call, so once the table has been analyzed, the planner's estimate is used
testDB := dbtest.CreateTestPostgres(t)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
for i := 0; i < 10; i++ {
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "some message")))
}
// Never analyzed: falls back to an exact count
count, err := store.MessagesCount()
require.Nil(t, err)
require.Equal(t, 10, count)
// Analyzed, then rows deleted: the estimate lags until the next (auto)analyze
_, err = testDB.Exec(`ANALYZE message`)
require.Nil(t, err)
_, err = testDB.Exec(`DELETE FROM message WHERE id IN (SELECT id FROM message LIMIT 4)`)
require.Nil(t, err)
count, err = store.MessagesCount()
require.Nil(t, err)
require.Equal(t, 10, count)
}
+145
View File
@@ -0,0 +1,145 @@
package message
import (
"database/sql"
"fmt"
"path/filepath"
"sync"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util"
)
// SQLite runtime query constants
const (
sqliteInsertMessageQuery = `
INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
sqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
sqliteDeleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
sqliteSelectMessagesByIDQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE mid = ?
`
sqliteSelectMessagesSinceTimeQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time DESC, id DESC
`
sqliteSelectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time DESC, id DESC
`
sqliteSelectMessagesSinceIDQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1
ORDER BY time DESC, id DESC
`
sqliteSelectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0)
ORDER BY time DESC, id DESC
`
sqliteSelectMessagesLatestQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND published = 1
ORDER BY time DESC, id DESC
LIMIT 1
`
sqliteSelectMessagesDueQuery = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE time <= ? AND published = 0
ORDER BY time, id
`
sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
sqliteDeleteExpiredMessagesQuery = `DELETE FROM messages WHERE mid IN (SELECT mid FROM messages WHERE expires <= ? AND published = 1 LIMIT ?)`
sqliteMarkExpiredAttachmentsDeletedQuery = `UPDATE messages SET attachment_deleted = 1 WHERE mid IN (SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0 LIMIT ?)`
sqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
sqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
sqliteSelectAttachmentsWithSizesQuery = `SELECT mid, attachment_size FROM messages WHERE attachment_expires > ? AND attachment_deleted = 0`
sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'`
sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'`
sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?`
)
var sqliteQueries = queries{
insertMessage: sqliteInsertMessageQuery,
selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery,
deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery,
updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery,
selectMessagesByID: sqliteSelectMessagesByIDQuery,
selectMessagesSinceTime: sqliteSelectMessagesSinceTimeQuery,
selectMessagesSinceTimeScheduled: sqliteSelectMessagesSinceTimeIncludeScheduledQuery,
selectMessagesSinceID: sqliteSelectMessagesSinceIDQuery,
selectMessagesSinceIDScheduled: sqliteSelectMessagesSinceIDIncludeScheduledQuery,
selectMessagesLatest: sqliteSelectMessagesLatestQuery,
selectMessagesDue: sqliteSelectMessagesDueQuery,
deleteExpiredMessages: sqliteDeleteExpiredMessagesQuery,
updateMessagePublished: sqliteUpdateMessagePublishedQuery,
selectMessagesCount: sqliteSelectMessagesCountQuery,
selectTopics: sqliteSelectTopicsQuery,
markExpiredAttachmentsDeleted: sqliteMarkExpiredAttachmentsDeletedQuery,
selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery,
selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery,
selectAttachmentsWithSizes: sqliteSelectAttachmentsWithSizesQuery,
selectStats: sqliteSelectStatsQuery,
updateStats: sqliteUpdateStatsQuery,
updateMessageTime: sqliteUpdateMessageTimeQuery,
}
// NewSQLiteStore creates a SQLite file-backed cache
func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*Cache, error) {
parentDir := filepath.Dir(filename)
if !util.FileExists(parentDir) {
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
}
d, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
return nil, err
}
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations(cacheDuration)); err != nil {
return nil, err
}
return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
}
// NewMemStore creates an in-memory cache
func NewMemStore() (*Cache, error) {
return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, false)
}
// NewNopStore creates an in-memory cache that discards all messages;
// it is always empty and can be used if caching is entirely disabled
func NewNopStore() (*Cache, error) {
return NewSQLiteStore(createMemoryFilename(), "", 0, 0, 0, true)
}
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
// From mattn/go-sqlite3: "Each connection to ":memory:" opens a brand new in-memory
// sql database, so if the stdlib's sql engine happens to open another connection and
// you've only specified ":memory:", that connection will see a brand new database.
// A workaround is to use "file::memory:?cache=shared" (or "file:foobar?mode=memory&cache=shared").
// Every connection to this string will point to the same in-memory database."
func createMemoryFilename() string {
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
}
+213
View File
@@ -0,0 +1,213 @@
package message
import (
"database/sql"
"time"
"heckel.io/ntfy/v2/db/schema"
)
// Initial SQLite schema
const (
sqliteCurrentSchemaVersion = 15
sqliteCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time INT NOT NULL,
event TEXT NOT NULL,
expires INT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size INT NOT NULL,
attachment_expires INT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_deleted INT NOT NULL,
sender TEXT NOT NULL,
user TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
CREATE TABLE IF NOT EXISTS stats (
key TEXT PRIMARY KEY,
value INT
);
INSERT INTO stats (key, value) VALUES ('messages', 0);
`
)
// Schema migrations for SQLite. Databases older than schema version 1 (ntfy < v1.10.0,
// November 2021) can no longer be migrated.
const (
// 1 -> 2
sqliteMigrate1To2AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
`
// 2 -> 3
sqliteMigrate2To3AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0');
ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0');
ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
`
// 3 -> 4
sqliteMigrate3To4AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
`
// 4 -> 5
sqliteMigrate4To5AlterMessagesTableQuery = `
CREATE TABLE IF NOT EXISTS messages_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
time INT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size INT NOT NULL,
attachment_expires INT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_owner TEXT NOT NULL,
encoding TEXT NOT NULL,
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid);
CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic);
INSERT
INTO messages_new (
mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
SELECT
id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published
FROM messages;
DROP TABLE messages;
ALTER TABLE messages_new RENAME TO messages;
`
// 5 -> 6
sqliteMigrate5To6AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
`
// 6 -> 7
sqliteMigrate6To7AlterMessagesTableQuery = `
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
`
// 7 -> 8
sqliteMigrate7To8AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
`
// 8 -> 9
sqliteMigrate8To9AlterMessagesTableQuery = `
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
`
// 9 -> 10
sqliteMigrate9To10AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
`
sqliteMigrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
// 10 -> 11
sqliteMigrate10To11AlterMessagesTableQuery = `
CREATE TABLE IF NOT EXISTS stats (
key TEXT PRIMARY KEY,
value INT
);
INSERT INTO stats (key, value) VALUES ('messages', 0);
`
// 11 -> 12
sqliteMigrate11To12AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
`
// 12 -> 13
sqliteMigrate12To13AlterMessagesTableQuery = `
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
`
// 13 -> 14
sqliteMigrate13To14AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
`
)
var (
sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQuery)
)
// sqliteMigrations returns the migration steps, keyed by the version they upgrade FROM. The
// cache duration is carried into the 9 -> 10 step via closure (it backfills "expires" from it).
// Always append migrations at the end, never insert in the middle.
func sqliteMigrations(cacheDuration time.Duration) map[int]schema.MigrateFunc {
return map[int]schema.MigrateFunc{
1: schema.AsMigrateFunc(sqliteMigrate1To2AlterMessagesTableQuery),
2: schema.AsMigrateFunc(sqliteMigrate2To3AlterMessagesTableQuery),
3: schema.AsMigrateFunc(sqliteMigrate3To4AlterMessagesTableQuery),
4: schema.AsMigrateFunc(sqliteMigrate4To5AlterMessagesTableQuery),
5: schema.AsMigrateFunc(sqliteMigrate5To6AlterMessagesTableQuery),
6: schema.AsMigrateFunc(sqliteMigrate6To7AlterMessagesTableQuery),
7: schema.AsMigrateFunc(sqliteMigrate7To8AlterMessagesTableQuery),
8: schema.AsMigrateFunc(sqliteMigrate8To9AlterMessagesTableQuery),
9: func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
return err
}
_, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds()))
return err
},
10: schema.AsMigrateFunc(sqliteMigrate10To11AlterMessagesTableQuery),
11: schema.AsMigrateFunc(sqliteMigrate11To12AlterMessagesTableQuery),
12: schema.AsMigrateFunc(sqliteMigrate12To13AlterMessagesTableQuery),
13: schema.AsMigrateFunc(sqliteMigrate13To14AlterMessagesTableQuery),
14: schema.NopMigrateFunc, // Corresponds to Postgres migration
}
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
}
return nil
}
+266
View File
@@ -0,0 +1,266 @@
package message_test
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func TestSqliteStore_Migration_From1(t *testing.T) {
filename := newSqliteTestStoreFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 1" schema
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY,
time INT NOT NULL,
topic VARCHAR(64) NOT NULL,
message VARCHAR(512) NOT NULL,
title VARCHAR(256) NOT NULL,
priority INT NOT NULL,
tags VARCHAR(256) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
`)
require.Nil(t, err)
// Insert a bunch of messages
for i := 0; i < 10; i++ {
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create store to trigger migration
s := newSqliteTestStoreFromFile(t, filename, "")
checkSqliteSchemaVersion(t, filename)
// The migrated database must be structurally identical to a freshly created one
freshFile := newSqliteTestStoreFile(t)
fresh, err := message.NewSQLiteStore(freshFile, "", time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { fresh.Close() })
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
// Add delayed message
delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message")
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
require.Nil(t, s.AddMessage(delayedMessage))
// 10, not 11!
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
// 11!
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 11, len(messages))
// Check that index "idx_topic" exists
verifyDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer verifyDB.Close()
rows, err := verifyDB.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`)
require.Nil(t, err)
require.True(t, rows.Next())
var indexName string
require.Nil(t, rows.Scan(&indexName))
require.Equal(t, "idx_topic", indexName)
require.Nil(t, rows.Close())
}
func TestSqliteStore_Migration_From9(t *testing.T) {
// This primarily tests the awkward migration that introduces the "expires" column.
// The migration logic has to update the column, using the existing "cache-duration" value.
filename := newSqliteTestStoreFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 9" schema
_, err = db.Exec(`
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
time INT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size INT NOT NULL,
attachment_expires INT NOT NULL,
attachment_url TEXT NOT NULL,
sender TEXT NOT NULL,
encoding TEXT NOT NULL,
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
COMMIT;
`)
require.Nil(t, err)
// Insert a bunch of messages
insertQuery := `
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
for i := 0; i < 10; i++ {
_, err = db.Exec(
insertQuery,
fmt.Sprintf("abcd%d", i),
time.Now().Unix(),
"mytopic",
fmt.Sprintf("some message %d", i),
"", // title
0, // priority
"", // tags
"", // click
"", // icon
"", // actions
"", // attachment_name
"", // attachment_type
0, // attachment_size
0, // attachment_expires
"", // attachment_url
"9.9.9.9", // sender
"", // encoding
1, // published
)
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create store to trigger migration
cacheDuration := 17 * time.Hour
s, err := message.NewSQLiteStore(filename, "", cacheDuration, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { s.Close() })
checkSqliteSchemaVersion(t, filename)
// Check version
verifyDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer verifyDB.Close()
rows, err := verifyDB.Query(`SELECT version FROM schemaVersion WHERE id = 1`)
require.Nil(t, err)
require.True(t, rows.Next())
var version int
require.Nil(t, rows.Scan(&version))
require.Equal(t, 15, version)
require.Nil(t, rows.Close())
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
for _, m := range messages {
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
}
}
func TestSqliteStore_StartupQueries_WAL(t *testing.T) {
filename := newSqliteTestStoreFile(t)
startupQueries := `pragma journal_mode = WAL;
pragma synchronous = normal;
pragma temp_store = memory;`
s, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { s.Close() })
require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "some message")))
require.FileExists(t, filename)
require.FileExists(t, filename+"-wal")
require.FileExists(t, filename+"-shm")
}
func TestSqliteStore_StartupQueries_None(t *testing.T) {
filename := newSqliteTestStoreFile(t)
s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { s.Close() })
require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "some message")))
require.FileExists(t, filename)
require.NoFileExists(t, filename+"-wal")
require.NoFileExists(t, filename+"-shm")
}
func TestSqliteStore_StartupQueries_Fail(t *testing.T) {
filename := newSqliteTestStoreFile(t)
_, err := message.NewSQLiteStore(filename, `xx error`, time.Hour, 0, 0, false)
require.Error(t, err)
}
func TestNopStore(t *testing.T) {
s, err := message.NewNopStore()
require.Nil(t, err)
t.Cleanup(func() { s.Close() })
require.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "my message")))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Empty(t, messages)
topics, err := s.Topics()
require.Nil(t, err)
require.Empty(t, topics)
}
func newSqliteTestStoreFile(t *testing.T) string {
return filepath.Join(t.TempDir(), "cache.db")
}
func newSqliteTestStoreFromFile(t *testing.T, filename, startupQueries string) *message.Cache {
s, err := message.NewSQLiteStore(filename, startupQueries, time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { s.Close() })
return s
}
func checkSqliteSchemaVersion(t *testing.T, filename string) {
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer db.Close()
rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err)
require.True(t, rows.Next())
var schemaVersion int
require.Nil(t, rows.Scan(&schemaVersion))
require.Equal(t, 15, schemaVersion)
require.Nil(t, rows.Close())
}
+949
View File
@@ -0,0 +1,949 @@
package message_test
import (
"net/netip"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func newSqliteTestStore(t *testing.T) *message.Cache {
filename := filepath.Join(t.TempDir(), "cache.db")
s, err := message.NewSQLiteStore(filename, "", time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { s.Close() })
return s
}
func newMemTestStore(t *testing.T) *message.Cache {
s, err := message.NewMemStore()
require.Nil(t, err)
t.Cleanup(func() { s.Close() })
return s
}
func newTestPostgresStore(t *testing.T) *message.Cache {
testDB := dbtest.CreateTestPostgres(t)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
return store
}
func forEachBackend(t *testing.T, f func(t *testing.T, s *message.Cache)) {
t.Run("sqlite", func(t *testing.T) {
f(t, newSqliteTestStore(t))
})
t.Run("mem", func(t *testing.T) {
f(t, newMemTestStore(t))
})
t.Run("postgres", func(t *testing.T) {
f(t, newTestPostgresStore(t))
})
}
func TestStore_Messages(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
m1 := model.NewDefaultMessage("mytopic", "my message")
m1.Time = 1
m2 := model.NewDefaultMessage("mytopic", "my other message")
m2.Time = 2
require.Nil(t, s.AddMessage(m1))
require.Nil(t, s.AddMessage(model.NewDefaultMessage("example", "my example message")))
require.Nil(t, s.AddMessage(m2))
// Adding invalid
require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewKeepaliveMessage("mytopic"))) // These should not be added!
require.Equal(t, model.ErrUnexpectedMessageType, s.AddMessage(model.NewOpenMessage("example"))) // These should not be added!
// count
count, err := s.MessagesCount()
require.Nil(t, err)
require.Equal(t, 3, count)
// mytopic: since all
messages, _ := s.Messages("mytopic", model.SinceAllMessages, false)
require.Equal(t, 2, len(messages))
require.Equal(t, "my message", messages[0].Message)
require.Equal(t, "mytopic", messages[0].Topic)
require.Equal(t, model.MessageEvent, messages[0].Event)
require.Equal(t, "", messages[0].Title)
require.Equal(t, 0, messages[0].Priority)
require.Nil(t, messages[0].Tags)
require.Equal(t, "my other message", messages[1].Message)
// mytopic: since none
messages, _ = s.Messages("mytopic", model.SinceNoMessages, false)
require.Empty(t, messages)
// mytopic: since m1 (by ID)
messages, _ = s.Messages("mytopic", model.NewSinceID(m1.ID), false)
require.Equal(t, 1, len(messages))
require.Equal(t, m2.ID, messages[0].ID)
require.Equal(t, "my other message", messages[0].Message)
require.Equal(t, "mytopic", messages[0].Topic)
// mytopic: since 2
messages, _ = s.Messages("mytopic", model.NewSinceTime(2), false)
require.Equal(t, 1, len(messages))
require.Equal(t, "my other message", messages[0].Message)
// mytopic: latest
messages, _ = s.Messages("mytopic", model.SinceLatestMessage, false)
require.Equal(t, 1, len(messages))
require.Equal(t, "my other message", messages[0].Message)
// example: since all
messages, _ = s.Messages("example", model.SinceAllMessages, false)
require.Equal(t, "my example message", messages[0].Message)
// non-existing: since all
messages, _ = s.Messages("doesnotexist", model.SinceAllMessages, false)
require.Empty(t, messages)
})
}
func TestStore_MessagesLock(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
var wg sync.WaitGroup
for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
assert.Nil(t, s.AddMessage(model.NewDefaultMessage("mytopic", "test message")))
wg.Done()
}()
}
wg.Wait()
})
}
func TestStore_MessagesScheduled(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
m1 := model.NewDefaultMessage("mytopic", "message 1")
m2 := model.NewDefaultMessage("mytopic", "message 2")
m2.Time = time.Now().Add(time.Hour).Unix()
m3 := model.NewDefaultMessage("mytopic", "message 3")
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
m4 := model.NewDefaultMessage("mytopic2", "message 4")
m4.Time = time.Now().Add(time.Minute).Unix()
require.Nil(t, s.AddMessage(m1))
require.Nil(t, s.AddMessage(m2))
require.Nil(t, s.AddMessage(m3))
messages, _ := s.Messages("mytopic", model.SinceAllMessages, false) // exclude scheduled
require.Equal(t, 1, len(messages))
require.Equal(t, "message 1", messages[0].Message)
messages, _ = s.Messages("mytopic", model.SinceAllMessages, true) // include scheduled
require.Equal(t, 3, len(messages))
require.Equal(t, "message 1", messages[0].Message)
require.Equal(t, "message 3", messages[1].Message) // Order!
require.Equal(t, "message 2", messages[2].Message)
messages, _ = s.MessagesDue()
require.Empty(t, messages)
})
}
func TestStore_Topics(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic1", "my example message")))
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 1")))
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 2")))
require.Nil(t, s.AddMessage(model.NewDefaultMessage("topic2", "message 3")))
topics, err := s.Topics()
if err != nil {
t.Fatal(err)
}
require.Equal(t, 2, len(topics))
require.Contains(t, topics, "topic1")
require.Contains(t, topics, "topic2")
})
}
func TestStore_MessagesTagsPrioAndTitle(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
m := model.NewDefaultMessage("mytopic", "some message")
m.Tags = []string{"tag1", "tag2"}
m.Priority = 5
m.Title = "some title"
require.Nil(t, s.AddMessage(m))
messages, _ := s.Messages("mytopic", model.SinceAllMessages, false)
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
require.Equal(t, 5, messages[0].Priority)
require.Equal(t, "some title", messages[0].Title)
})
}
func TestStore_MessagesSinceID(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
m1 := model.NewDefaultMessage("mytopic", "message 1")
m1.Time = 100
m2 := model.NewDefaultMessage("mytopic", "message 2")
m2.Time = 200
m3 := model.NewDefaultMessage("mytopic", "message 3")
m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5
m4 := model.NewDefaultMessage("mytopic", "message 4")
m4.Time = 400
m5 := model.NewDefaultMessage("mytopic", "message 5")
m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7
m6 := model.NewDefaultMessage("mytopic", "message 6")
m6.Time = 600
m7 := model.NewDefaultMessage("mytopic", "message 7")
m7.Time = 700
require.Nil(t, s.AddMessage(m1))
require.Nil(t, s.AddMessage(m2))
require.Nil(t, s.AddMessage(m3))
require.Nil(t, s.AddMessage(m4))
require.Nil(t, s.AddMessage(m5))
require.Nil(t, s.AddMessage(m6))
require.Nil(t, s.AddMessage(m7))
// Case 1: Since ID exists, exclude scheduled
messages, _ := s.Messages("mytopic", model.NewSinceID(m2.ID), false)
require.Equal(t, 3, len(messages))
require.Equal(t, "message 4", messages[0].Message)
require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5!
require.Equal(t, "message 7", messages[2].Message)
// Case 2: Since ID exists, include scheduled
messages, _ = s.Messages("mytopic", model.NewSinceID(m2.ID), true)
require.Equal(t, 5, len(messages))
require.Equal(t, "message 4", messages[0].Message)
require.Equal(t, "message 6", messages[1].Message)
require.Equal(t, "message 7", messages[2].Message)
require.Equal(t, "message 5", messages[3].Message) // Order!
require.Equal(t, "message 3", messages[4].Message) // Order!
// Case 3: Since ID does not exist (-> Return all messages), include scheduled
messages, _ = s.Messages("mytopic", model.NewSinceID("doesntexist"), true)
require.Equal(t, 7, len(messages))
require.Equal(t, "message 1", messages[0].Message)
require.Equal(t, "message 2", messages[1].Message)
require.Equal(t, "message 4", messages[2].Message)
require.Equal(t, "message 6", messages[3].Message)
require.Equal(t, "message 7", messages[4].Message)
require.Equal(t, "message 5", messages[5].Message) // Order!
require.Equal(t, "message 3", messages[6].Message) // Order!
// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled
messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), false)
require.Equal(t, 0, len(messages))
// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled
messages, _ = s.Messages("mytopic", model.NewSinceID(m7.ID), true)
require.Equal(t, 2, len(messages))
require.Equal(t, "message 5", messages[0].Message)
require.Equal(t, "message 3", messages[1].Message)
})
}
func TestStore_Prune(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
now := time.Now().Unix()
m1 := model.NewDefaultMessage("mytopic", "my message")
m1.Time = now - 10
m1.Expires = now - 5
m2 := model.NewDefaultMessage("mytopic", "my other message")
m2.Time = now - 5
m2.Expires = now + 5 // In the future
m3 := model.NewDefaultMessage("another_topic", "and another one")
m3.Time = now - 12
m3.Expires = now - 2
require.Nil(t, s.AddMessage(m1))
require.Nil(t, s.AddMessage(m2))
require.Nil(t, s.AddMessage(m3))
count, err := s.MessagesCount()
require.Nil(t, err)
require.Equal(t, 3, count)
deleted, err := s.DeleteExpiredMessages(10)
require.Nil(t, err)
require.Equal(t, int64(2), deleted)
count, err = s.MessagesCount()
require.Nil(t, err)
require.Equal(t, 1, count)
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "my other message", messages[0].Message)
})
}
func TestStore_Attachments(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
m := model.NewDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
m.SequenceID = "m1"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &model.Attachment{
Name: "flower.jpg",
Type: "image/jpeg",
Size: 5000,
Expires: expires1,
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
}
require.Nil(t, s.AddMessage(m))
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = model.NewDefaultMessage("mytopic", "sending you a car")
m.ID = "m2"
m.SequenceID = "m2"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &model.Attachment{
Name: "car.jpg",
Type: "image/jpeg",
Size: 10000,
Expires: expires2,
URL: "https://ntfy.sh/file/aCaRURL.jpg",
}
require.Nil(t, s.AddMessage(m))
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = model.NewDefaultMessage("another-topic", "sending you another car")
m.ID = "m3"
m.SequenceID = "m3"
m.User = "u_BAsbaAa"
m.Sender = netip.MustParseAddr("5.6.7.8")
m.Attachment = &model.Attachment{
Name: "another-car.jpg",
Type: "image/jpeg",
Size: 20000,
Expires: expires3,
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
}
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
require.Equal(t, "flower for you", messages[0].Message)
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
require.Equal(t, int64(5000), messages[0].Attachment.Size)
require.Equal(t, expires1, messages[0].Attachment.Expires)
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[0].Sender.String())
require.Equal(t, "sending you a car", messages[1].Message)
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
require.Equal(t, int64(10000), messages[1].Attachment.Size)
require.Equal(t, expires2, messages[1].Attachment.Expires)
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
size, err := s.AttachmentBytesUsedBySender("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(10000), size)
size, err = s.AttachmentBytesUsedBySender("5.6.7.8")
require.Nil(t, err)
require.Equal(t, int64(0), size) // Accounted to the user, not the IP!
size, err = s.AttachmentBytesUsedByUser("u_BAsbaAa")
require.Nil(t, err)
require.Equal(t, int64(20000), size)
})
}
func TestStore_AttachmentsExpired(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
m := model.NewDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
m.SequenceID = "m1"
m.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, s.AddMessage(m))
m = model.NewDefaultMessage("mytopic", "message with attachment")
m.ID = "m2"
m.SequenceID = "m2"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &model.Attachment{
Name: "car.jpg",
Type: "image/jpeg",
Size: 10000,
Expires: time.Now().Add(2 * time.Hour).Unix(),
URL: "https://ntfy.sh/file/aCaRURL.jpg",
}
require.Nil(t, s.AddMessage(m))
m = model.NewDefaultMessage("mytopic", "message with external attachment")
m.ID = "m3"
m.SequenceID = "m3"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &model.Attachment{
Name: "car.jpg",
Type: "image/jpeg",
Expires: 0, // Unknown!
URL: "https://somedomain.com/car.jpg",
}
require.Nil(t, s.AddMessage(m))
m = model.NewDefaultMessage("mytopic2", "message with expired attachment")
m.ID = "m4"
m.SequenceID = "m4"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &model.Attachment{
Name: "expired-car.jpg",
Type: "image/jpeg",
Size: 20000,
Expires: time.Now().Add(-1 * time.Hour).Unix(),
URL: "https://ntfy.sh/file/aCaRURL.jpg",
}
require.Nil(t, s.AddMessage(m))
count, err := s.MarkExpiredAttachmentsDeleted(10)
require.Nil(t, err)
require.Equal(t, int64(1), count)
})
}
func TestStore_Sender(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
m1 := model.NewDefaultMessage("mytopic", "mymessage")
m1.Sender = netip.MustParseAddr("1.2.3.4")
require.Nil(t, s.AddMessage(m1))
m2 := model.NewDefaultMessage("mytopic", "mymessage without sender")
require.Nil(t, s.AddMessage(m2))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
require.Equal(t, messages[1].Sender, netip.Addr{})
})
}
func TestStore_DeleteScheduledBySequenceID(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Create a scheduled (unpublished) message
scheduledMsg := model.NewDefaultMessage("mytopic", "scheduled message")
scheduledMsg.ID = "scheduled1"
scheduledMsg.SequenceID = "seq123"
scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
require.Nil(t, s.AddMessage(scheduledMsg))
// Create a published message with different sequence ID
publishedMsg := model.NewDefaultMessage("mytopic", "published message")
publishedMsg.ID = "published1"
publishedMsg.SequenceID = "seq456"
publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
require.Nil(t, s.AddMessage(publishedMsg))
// Create a scheduled message in a different topic
otherTopicMsg := model.NewDefaultMessage("othertopic", "other scheduled")
otherTopicMsg.ID = "other1"
otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
require.Nil(t, s.AddMessage(otherTopicMsg))
// Verify all messages exist (including scheduled)
messages, err := s.Messages("mytopic", model.SinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
messages, err = s.Messages("othertopic", model.SinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
// Delete scheduled message by sequence ID and verify returned IDs
deletedIDs, err := s.DeleteScheduledBySequenceID("mytopic", "seq123")
require.Nil(t, err)
require.Equal(t, 1, len(deletedIDs))
require.Equal(t, "scheduled1", deletedIDs[0])
// Verify scheduled message is deleted
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "published message", messages[0].Message)
// Verify other topic's message still exists (topic-scoped deletion)
messages, err = s.Messages("othertopic", model.SinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "other scheduled", messages[0].Message)
// Deleting non-existent sequence ID should return empty list
deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "nonexistent")
require.Nil(t, err)
require.Empty(t, deletedIDs)
// Deleting published message should not affect it (only deletes unpublished)
deletedIDs, err = s.DeleteScheduledBySequenceID("mytopic", "seq456")
require.Nil(t, err)
require.Empty(t, deletedIDs)
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "published message", messages[0].Message)
})
}
func TestStore_MessageByID(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Add a message
m := model.NewDefaultMessage("mytopic", "some message")
m.Title = "some title"
m.Priority = 4
m.Tags = []string{"tag1", "tag2"}
require.Nil(t, s.AddMessage(m))
// Retrieve by ID
retrieved, err := s.Message(m.ID)
require.Nil(t, err)
require.Equal(t, m.ID, retrieved.ID)
require.Equal(t, "mytopic", retrieved.Topic)
require.Equal(t, "some message", retrieved.Message)
require.Equal(t, "some title", retrieved.Title)
require.Equal(t, 4, retrieved.Priority)
require.Equal(t, []string{"tag1", "tag2"}, retrieved.Tags)
// Non-existent ID returns ErrMessageNotFound
_, err = s.Message("doesnotexist")
require.Equal(t, model.ErrMessageNotFound, err)
})
}
func TestStore_MarkPublished(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Add a scheduled message (future time -> unpublished)
m := model.NewDefaultMessage("mytopic", "scheduled message")
m.Time = time.Now().Add(time.Hour).Unix()
require.Nil(t, s.AddMessage(m))
// Verify it does not appear in non-scheduled queries
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 0, len(messages))
// Verify it does appear in scheduled queries
messages, err = s.Messages("mytopic", model.SinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
// Mark as published
require.Nil(t, s.MarkPublished(m))
// Now it should appear in non-scheduled queries too
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "scheduled message", messages[0].Message)
})
}
func TestStore_ExpireMessages(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Add messages to two topics
m1 := model.NewDefaultMessage("topic1", "message 1")
m1.Expires = time.Now().Add(time.Hour).Unix()
m2 := model.NewDefaultMessage("topic1", "message 2")
m2.Expires = time.Now().Add(time.Hour).Unix()
m3 := model.NewDefaultMessage("topic2", "message 3")
m3.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, s.AddMessage(m1))
require.Nil(t, s.AddMessage(m2))
require.Nil(t, s.AddMessage(m3))
// Verify all messages exist
messages, err := s.Messages("topic1", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
messages, err = s.Messages("topic2", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
// Expire topic1 messages
require.Nil(t, s.ExpireMessages("topic1"))
// topic1 messages should now be expired (expires set to past)
deleted, err := s.DeleteExpiredMessages(100)
require.Nil(t, err)
require.Equal(t, int64(2), deleted)
// topic2 should be unaffected
messages, err = s.Messages("topic2", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "message 3", messages[0].Message)
})
}
func TestStore_MarkAttachmentsDeleted(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Add a message with an expired attachment (file needs cleanup)
m1 := model.NewDefaultMessage("mytopic", "old file")
m1.ID = "msg1"
m1.SequenceID = "msg1"
m1.Expires = time.Now().Add(time.Hour).Unix()
m1.Attachment = &model.Attachment{
Name: "old.pdf",
Type: "application/pdf",
Size: 50000,
Expires: time.Now().Add(-time.Hour).Unix(), // Expired
URL: "https://ntfy.sh/file/old.pdf",
}
require.Nil(t, s.AddMessage(m1))
// Add a message with another expired attachment
m2 := model.NewDefaultMessage("mytopic", "another old file")
m2.ID = "msg2"
m2.SequenceID = "msg2"
m2.Expires = time.Now().Add(time.Hour).Unix()
m2.Attachment = &model.Attachment{
Name: "another.pdf",
Type: "application/pdf",
Size: 30000,
Expires: time.Now().Add(-time.Hour).Unix(), // Expired
URL: "https://ntfy.sh/file/another.pdf",
}
require.Nil(t, s.AddMessage(m2))
// Both should be marked as deleted in one batch
count, err := s.MarkExpiredAttachmentsDeleted(10)
require.Nil(t, err)
require.Equal(t, int64(2), count)
// No more expired attachments to clean up
count, err = s.MarkExpiredAttachmentsDeleted(10)
require.Nil(t, err)
require.Equal(t, int64(0), count)
// Messages themselves still exist
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
})
}
func TestStore_Stats(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Initial stats should be zero
messages, err := s.Stats()
require.Nil(t, err)
require.Equal(t, int64(0), messages)
// Update stats
require.Nil(t, s.UpdateStats(42))
messages, err = s.Stats()
require.Nil(t, err)
require.Equal(t, int64(42), messages)
// Update again (overwrites)
require.Nil(t, s.UpdateStats(100))
messages, err = s.Stats()
require.Nil(t, err)
require.Equal(t, int64(100), messages)
})
}
func TestStore_AddMessages(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Batch add multiple messages
msgs := []*model.Message{
model.NewDefaultMessage("mytopic", "batch 1"),
model.NewDefaultMessage("mytopic", "batch 2"),
model.NewDefaultMessage("othertopic", "batch 3"),
}
require.Nil(t, s.AddMessages(msgs))
// Verify all were inserted
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
messages, err = s.Messages("othertopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "batch 3", messages[0].Message)
// Empty batch should succeed
require.Nil(t, s.AddMessages([]*model.Message{}))
// Batch with invalid event type should fail
badMsgs := []*model.Message{
model.NewKeepaliveMessage("mytopic"),
}
require.NotNil(t, s.AddMessages(badMsgs))
})
}
func TestStore_MessagesDue(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Add a message scheduled in the past (i.e. it's due now)
m1 := model.NewDefaultMessage("mytopic", "due message")
m1.Time = time.Now().Add(-time.Second).Unix()
// Set expires in the future so it doesn't get pruned
m1.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, s.AddMessage(m1))
// Add a message scheduled in the future (not due)
m2 := model.NewDefaultMessage("mytopic", "future message")
m2.Time = time.Now().Add(time.Hour).Unix()
require.Nil(t, s.AddMessage(m2))
// Mark m1 as published so it won't be "due"
// (MessagesDue returns unpublished messages whose time <= now)
// m1 is auto-published (time <= now), so it should not be due
// m2 is unpublished (time in future), not due yet
due, err := s.MessagesDue()
require.Nil(t, err)
require.Equal(t, 0, len(due))
// Add a message that was explicitly scheduled in the past but time has "arrived"
// We need to manipulate the database to create a truly "due" message:
// a message with published=false and time <= now
m3 := model.NewDefaultMessage("mytopic", "truly due message")
m3.Time = time.Now().Add(2 * time.Second).Unix() // 2 seconds from now
require.Nil(t, s.AddMessage(m3))
// Not due yet
due, err = s.MessagesDue()
require.Nil(t, err)
require.Equal(t, 0, len(due))
// Wait for it to become due
time.Sleep(3 * time.Second)
due, err = s.MessagesDue()
require.Nil(t, err)
require.Equal(t, 1, len(due))
require.Equal(t, "truly due message", due[0].Message)
})
}
func TestStore_MessageFieldRoundTrip(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Create a message with all fields populated
m := model.NewDefaultMessage("mytopic", "hello world")
m.SequenceID = "custom_seq_id"
m.Title = "A Title"
m.Priority = 4
m.Tags = []string{"warning", "srv01"}
m.Click = "https://example.com/click"
m.Icon = "https://example.com/icon.png"
m.Actions = []*model.Action{
{
ID: "action1",
Action: "view",
Label: "Open Site",
URL: "https://example.com",
Clear: true,
},
{
ID: "action2",
Action: "http",
Label: "Call Webhook",
URL: "https://example.com/hook",
Method: "PUT",
Headers: map[string]string{"X-Token": "secret"},
Body: `{"key":"value"}`,
},
}
m.ContentType = "text/markdown"
m.Encoding = "base64"
m.Sender = netip.MustParseAddr("9.8.7.6")
m.User = "u_TestUser123"
require.Nil(t, s.AddMessage(m))
// Retrieve and verify every field
retrieved, err := s.Message(m.ID)
require.Nil(t, err)
require.Equal(t, m.ID, retrieved.ID)
require.Equal(t, "custom_seq_id", retrieved.SequenceID)
require.Equal(t, m.Time, retrieved.Time)
require.Equal(t, m.Expires, retrieved.Expires)
require.Equal(t, model.MessageEvent, retrieved.Event)
require.Equal(t, "mytopic", retrieved.Topic)
require.Equal(t, "hello world", retrieved.Message)
require.Equal(t, "A Title", retrieved.Title)
require.Equal(t, 4, retrieved.Priority)
require.Equal(t, []string{"warning", "srv01"}, retrieved.Tags)
require.Equal(t, "https://example.com/click", retrieved.Click)
require.Equal(t, "https://example.com/icon.png", retrieved.Icon)
require.Equal(t, "text/markdown", retrieved.ContentType)
require.Equal(t, "base64", retrieved.Encoding)
require.Equal(t, netip.MustParseAddr("9.8.7.6"), retrieved.Sender)
require.Equal(t, "u_TestUser123", retrieved.User)
// Verify actions round-trip
require.Equal(t, 2, len(retrieved.Actions))
require.Equal(t, "action1", retrieved.Actions[0].ID)
require.Equal(t, "view", retrieved.Actions[0].Action)
require.Equal(t, "Open Site", retrieved.Actions[0].Label)
require.Equal(t, "https://example.com", retrieved.Actions[0].URL)
require.Equal(t, true, retrieved.Actions[0].Clear)
require.Equal(t, "action2", retrieved.Actions[1].ID)
require.Equal(t, "http", retrieved.Actions[1].Action)
require.Equal(t, "Call Webhook", retrieved.Actions[1].Label)
require.Equal(t, "https://example.com/hook", retrieved.Actions[1].URL)
require.Equal(t, "PUT", retrieved.Actions[1].Method)
require.Equal(t, "secret", retrieved.Actions[1].Headers["X-Token"])
require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body)
})
}
func TestStore_AddMessage_InvalidUTF8(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// 0xc9 0x43: Latin-1 "ÉC" — 0xc9 starts a 2-byte UTF-8 sequence but 0x43 ('C') is not a continuation byte
m := model.NewDefaultMessage("mytopic", "\xc9Cas du serveur")
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "\uFFFDCas du serveur", messages[0].Message)
// 0xae: Latin-1 "®" — isolated byte above 0x7F, not a valid UTF-8 start for single byte
m2 := model.NewDefaultMessage("mytopic", "Product\xae Pro")
require.Nil(t, s.AddMessage(m2))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "Product\uFFFD Pro", messages[1].Message)
// 0xe8 0x6d 0x65: Latin-1 "ème" — 0xe8 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte
m3 := model.NewDefaultMessage("mytopic", "probl\xe8me critique")
require.Nil(t, s.AddMessage(m3))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "probl\uFFFDme critique", messages[2].Message)
// 0xb2: Latin-1 "²" — isolated byte in 0x80-0xBF range (UTF-8 continuation byte without lead)
m4 := model.NewDefaultMessage("mytopic", "CO\xb2 level high")
require.Nil(t, s.AddMessage(m4))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "CO\uFFFD level high", messages[3].Message)
// 0xe9 0x6d 0x61: Latin-1 "éma" — 0xe9 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte
m5 := model.NewDefaultMessage("mytopic", "th\xe9matique")
require.Nil(t, s.AddMessage(m5))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "th\uFFFDmatique", messages[4].Message)
// 0xed 0x64 0x65: Latin-1 "íde" — 0xed starts a 3-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte
m6 := model.NewDefaultMessage("mytopic", "vid\xed\x64eo surveillance")
require.Nil(t, s.AddMessage(m6))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "vid\uFFFDdeo surveillance", messages[5].Message)
// 0xf3 0x6e 0x3a 0x20: Latin-1 "ón: " — 0xf3 starts a 4-byte UTF-8 sequence but 0x6e ('n') is not a continuation byte
m7 := model.NewDefaultMessage("mytopic", "notificaci\xf3n: alerta")
require.Nil(t, s.AddMessage(m7))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "notificaci\uFFFDn: alerta", messages[6].Message)
// 0xb7: Latin-1 "·" — isolated continuation byte
m8 := model.NewDefaultMessage("mytopic", "item\xb7value")
require.Nil(t, s.AddMessage(m8))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "item\uFFFDvalue", messages[7].Message)
// 0xa8: Latin-1 "¨" — isolated continuation byte
m9 := model.NewDefaultMessage("mytopic", "na\xa8ve")
require.Nil(t, s.AddMessage(m9))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "na\uFFFDve", messages[8].Message)
// 0xdf 0x64: Latin-1 "ßd" — 0xdf starts a 2-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte
m10 := model.NewDefaultMessage("mytopic", "gro\xdf\x64ruck")
require.Nil(t, s.AddMessage(m10))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "gro\uFFFDdruck", messages[9].Message)
// 0xe4 0x67 0x74: Latin-1 "ägt" — 0xe4 starts a 3-byte UTF-8 sequence but 0x67 ('g') is not a continuation byte
m11 := model.NewDefaultMessage("mytopic", "tr\xe4gt Last")
require.Nil(t, s.AddMessage(m11))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "tr\uFFFDgt Last", messages[10].Message)
// 0xe9 0x65 0x20: Latin-1 "ée " — 0xe9 starts a 3-byte UTF-8 sequence but 0x65 ('e') is not a continuation byte
m12 := model.NewDefaultMessage("mytopic", "journ\xe9\x65 termin\xe9\x65")
require.Nil(t, s.AddMessage(m12))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "journ\uFFFDe termin\uFFFDe", messages[11].Message)
})
}
func TestStore_AddMessage_NullByte(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// 0x00: NUL byte — valid UTF-8 but rejected by PostgreSQL
m := model.NewDefaultMessage("mytopic", "hello\x00world")
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "helloworld", messages[0].Message)
})
}
func TestStore_AddMessage_InvalidUTF8InTitleAndTags(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Invalid UTF-8 can arrive via HTTP headers (Title, Tags) which bypass body validation
m := model.NewDefaultMessage("mytopic", "valid message")
m.Title = "\xc9clipse du syst\xe8me"
m.Tags = []string{"probl\xe8me", "syst\xe9me"}
m.Click = "https://example.com/\xae"
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "\uFFFDclipse du syst\uFFFDme", messages[0].Title)
require.Equal(t, "probl\uFFFDme", messages[0].Tags[0])
require.Equal(t, "syst\uFFFDme", messages[0].Tags[1])
require.Equal(t, "https://example.com/\uFFFD", messages[0].Click)
})
}
func TestStore_AddMessage_InvalidUTF8BatchDoesNotDropValidMessages(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Previously, a single invalid message would roll back the entire batch transaction.
// Sanitization ensures all messages in a batch are written successfully.
msgs := []*model.Message{
model.NewDefaultMessage("mytopic", "valid message 1"),
model.NewDefaultMessage("mytopic", "notificaci\xf3n: alerta"),
model.NewDefaultMessage("mytopic", "valid message 3"),
}
require.Nil(t, s.AddMessages(msgs))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 3, len(messages))
})
}
+107
View File
@@ -0,0 +1,107 @@
// Package metrics defines the Prometheus metrics exposed by the ntfy server, and registers them
// with the default Prometheus registry on import. It is decoupled from the ntfy server, so that
// call sites can update metrics without depending on the server package.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
)
// Collectors for all metrics exposed by the server.
//
// These are never nil, so that call sites can update them unconditionally. If metrics are
// disabled, the server never mounts the /metrics handler, and the values are simply never read.
var (
MessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_success",
})
MessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_failure",
})
MessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_messages_cached_total",
})
MessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_message_publish_duration_ms",
})
FirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_success",
})
FirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_failure",
})
EmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_success",
})
EmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_failure",
})
EmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_success",
})
EmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_failure",
})
CallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_success",
})
CallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_failure",
})
UnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_unifiedpush_published_success",
})
MatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_success",
})
MatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_failure",
})
AttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_attachments_total_size",
})
Visitors = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_visitors_total",
})
Users = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_users_total",
})
Subscribers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_subscribers_total",
})
Topics = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_topics_total",
})
HTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_http_requests_total",
}, []string{"http_code", "ntfy_code", "http_method"})
)
// init registers all collectors with the default Prometheus registry. Registration is
// unconditional: the collectors are only ever exposed if the server mounts the /metrics handler,
// so there is nothing to be gained by tying registration to the config.
func init() {
prometheus.MustRegister(
MessagesPublishedSuccess,
MessagesPublishedFailure,
MessagesCached,
MessagePublishDurationMillis,
FirebasePublishedSuccess,
FirebasePublishedFailure,
EmailsPublishedSuccess,
EmailsPublishedFailure,
EmailsReceivedSuccess,
EmailsReceivedFailure,
CallsMadeSuccess,
CallsMadeFailure,
UnifiedPushPublishedSuccess,
MatrixPublishedSuccess,
MatrixPublishedFailure,
AttachmentsTotalSize,
Visitors,
Users,
Subscribers,
Topics,
HTTPRequests,
)
}
+58
View File
@@ -0,0 +1,58 @@
package metrics
import (
"sort"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
// expectedMetricNames is the exact set of metrics the server exposes. These names are a public
// contract: renaming or dropping one silently breaks existing dashboards and alerts.
var expectedMetricNames = []string{
"ntfy_attachments_total_size",
"ntfy_calls_made_failure",
"ntfy_calls_made_success",
"ntfy_emails_received_failure",
"ntfy_emails_received_success",
"ntfy_emails_sent_failure",
"ntfy_emails_sent_success",
"ntfy_firebase_published_failure",
"ntfy_firebase_published_success",
"ntfy_http_requests_total",
"ntfy_matrix_published_failure",
"ntfy_matrix_published_success",
"ntfy_message_publish_duration_ms",
"ntfy_messages_cached_total",
"ntfy_messages_published_failure",
"ntfy_messages_published_success",
"ntfy_subscribers_total",
"ntfy_topics_total",
"ntfy_unifiedpush_published_success",
"ntfy_users_total",
"ntfy_visitors_total",
}
func TestRegisteredMetricNames(t *testing.T) {
HTTPRequests.WithLabelValues("200", "20000", "GET").Inc()
families, err := prometheus.DefaultGatherer.Gather()
require.Nil(t, err)
names := make([]string, 0)
for _, family := range families {
if strings.HasPrefix(family.GetName(), "ntfy_") {
names = append(names, family.GetName())
}
}
sort.Strings(names)
require.Equal(t, expectedMetricNames, names)
}
func TestCollectors_NeverNil(t *testing.T) {
// Call sites update metrics unconditionally, even when metrics are disabled, so no collector
// may ever be nil
MessagesPublishedSuccess.Inc()
MessagesCached.Set(1)
HTTPRequests.WithLabelValues("200", "20000", "PUT").Inc()
}
+5
View File
@@ -42,8 +42,13 @@ extra:
link: https://github.com/binwiederhier
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
+267
View File
@@ -0,0 +1,267 @@
package model
import (
"errors"
"net/netip"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
// List of possible events
const (
OpenEvent = "open"
KeepaliveEvent = "keepalive"
MessageEvent = "message"
MessageDeleteEvent = "message_delete"
MessageClearEvent = "message_clear"
PollRequestEvent = "poll_request"
)
// messageIDLength is the length of a randomly generated message ID
const messageIDLength = 12
// Errors for message operations
var (
ErrUnexpectedMessageType = errors.New("unexpected message type")
ErrMessageNotFound = errors.New("message not found")
)
// Message represents a message published to a topic
type Message struct {
ID string `json:"id"` // Random message ID
SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID)
Time int64 `json:"time"` // Unix time in seconds
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Icon string `json:"icon,omitempty"`
Actions []*Action `json:"actions,omitempty"`
Attachment *Attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
User string `json:"-"` // UserID of the uploader, used to associated attachments
}
// Context returns a log context for the message
func (m *Message) Context() log.Context {
fields := map[string]any{
"topic": m.Topic,
"message_id": m.ID,
"message_sequence_id": m.SequenceID,
"message_time": m.Time,
"message_event": m.Event,
"message_body_size": len(m.Message),
}
if m.Sender.IsValid() {
fields["message_sender"] = m.Sender.String()
}
if m.User != "" {
fields["message_user"] = m.User
}
return fields
}
// SanitizeUTF8 replaces invalid UTF-8 sequences and strips NUL bytes from all user-supplied
// string fields. This is called early in the publish path so that all downstream consumers
// (Firebase, WebPush, SMTP, cache) receive clean UTF-8 strings.
func (m *Message) SanitizeUTF8() {
m.Topic = util.SanitizeUTF8(m.Topic)
m.Message = util.SanitizeUTF8(m.Message)
m.Title = util.SanitizeUTF8(m.Title)
m.Click = util.SanitizeUTF8(m.Click)
m.Icon = util.SanitizeUTF8(m.Icon)
m.ContentType = util.SanitizeUTF8(m.ContentType)
for i, tag := range m.Tags {
m.Tags[i] = util.SanitizeUTF8(tag)
}
if m.Attachment != nil {
m.Attachment.Name = util.SanitizeUTF8(m.Attachment.Name)
m.Attachment.Type = util.SanitizeUTF8(m.Attachment.Type)
m.Attachment.URL = util.SanitizeUTF8(m.Attachment.URL)
}
}
// ForJSON returns a copy of the message suitable for JSON output.
// It clears the SequenceID if it equals the ID to reduce redundancy.
func (m *Message) ForJSON() *Message {
if m.SequenceID == m.ID {
clone := *m
clone.SequenceID = ""
return &clone
}
return m
}
// Size returns an approximate byte size of the variable-length, publisher-controlled parts of a
// message. It is used to budget cache replays, so it deliberately counts every field a publisher
// can grow rather than trying to match the exact wire size.
func (m *Message) Size() int {
size := len(m.ID) + len(m.SequenceID) + len(m.Event) + len(m.Topic) + len(m.Title) +
len(m.Message) + len(m.Click) + len(m.Icon) + len(m.ContentType) + len(m.Encoding) + len(m.PollID)
for _, tag := range m.Tags {
size += len(tag)
}
for _, action := range m.Actions {
size += action.Size()
}
if m.Attachment != nil {
size += len(m.Attachment.Name) + len(m.Attachment.Type) + len(m.Attachment.URL)
}
return size
}
// Attachment represents a file attachment on a message
type Attachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Size int64 `json:"size,omitempty"`
Expires int64 `json:"expires,omitempty"`
URL string `json:"url"`
}
// Action represents a user-defined action on a message
type Action struct {
ID string `json:"id"`
Action string `json:"action"` // "view", "broadcast", "http", or "copy"
Label string `json:"label"` // action button label
Clear bool `json:"clear"` // clear notification after successful execution
URL string `json:"url,omitempty"` // used in "view" and "http" actions
Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
Headers map[string]string `json:"headers,omitempty"` // used in "http" action
Body string `json:"body,omitempty"` // used in "http" action
Intent string `json:"intent,omitempty"` // used in "broadcast" action
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
Value string `json:"value,omitempty"` // used in "copy" action
}
// Size returns an approximate byte size of an action's variable-length fields.
func (a *Action) Size() int {
size := len(a.ID) + len(a.Action) + len(a.Label) + len(a.URL) + len(a.Method) + len(a.Body) + len(a.Intent) + len(a.Value)
for key, value := range a.Headers {
size += len(key) + len(value)
}
for key, value := range a.Extras {
size += len(key) + len(value)
}
return size
}
// NewAction creates a new action with initialized maps
func NewAction() *Action {
return &Action{
Headers: make(map[string]string),
Extras: make(map[string]string),
}
}
// GenerateMessageID creates a new random message ID
func GenerateMessageID() string {
return util.RandomString(messageIDLength)
}
// ValidMessageID returns true if the given string is a valid message ID
func ValidMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength)
}
// NewMessage creates a new message with the current timestamp
func NewMessage(event, topic, msg string) *Message {
return &Message{
ID: GenerateMessageID(),
Time: time.Now().Unix(),
Event: event,
Topic: topic,
Message: msg,
}
}
// NewOpenMessage is a convenience method to create an open message
func NewOpenMessage(topic string) *Message {
return NewMessage(OpenEvent, topic, "")
}
// NewKeepaliveMessage is a convenience method to create a keepalive message
func NewKeepaliveMessage(topic string) *Message {
return NewMessage(KeepaliveEvent, topic, "")
}
// NewDefaultMessage is a convenience method to create a notification message
func NewDefaultMessage(topic, msg string) *Message {
return NewMessage(MessageEvent, topic, msg)
}
// NewActionMessage creates a new action message (message_delete or message_clear)
func NewActionMessage(event, topic, sequenceID string) *Message {
m := NewMessage(event, topic, "")
m.SequenceID = sequenceID
return m
}
// NewPollRequestMessage is a convenience method to create a poll request message
func NewPollRequestMessage(topic, pollID string) *Message {
m := NewMessage(PollRequestEvent, topic, "New message")
m.PollID = pollID
return m
}
// SinceMarker represents a point in time or message ID from which to retrieve messages
type SinceMarker struct {
time time.Time
id string
}
// NewSinceTime creates a new SinceMarker from a Unix timestamp
func NewSinceTime(timestamp int64) SinceMarker {
return SinceMarker{time.Unix(timestamp, 0), ""}
}
// NewSinceID creates a new SinceMarker from a message ID
func NewSinceID(id string) SinceMarker {
return SinceMarker{time.Unix(0, 0), id}
}
// IsAll returns true if this is the "all messages" marker
func (t SinceMarker) IsAll() bool {
return t == SinceAllMessages
}
// IsNone returns true if this is the "no messages" marker
func (t SinceMarker) IsNone() bool {
return t == SinceNoMessages
}
// IsLatest returns true if this is the "latest message" marker
func (t SinceMarker) IsLatest() bool {
return t == SinceLatestMessage
}
// IsID returns true if this marker references a specific message ID
func (t SinceMarker) IsID() bool {
return t.id != "" && t.id != SinceLatestMessage.id
}
// Time returns the time component of the marker
func (t SinceMarker) Time() time.Time {
return t.time
}
// ID returns the message ID component of the marker
func (t SinceMarker) ID() string {
return t.id
}
// Common SinceMarker values for subscribing to messages
var (
SinceAllMessages = SinceMarker{time.Unix(0, 0), ""}
SinceNoMessages = SinceMarker{time.Unix(1, 0), ""}
SinceLatestMessage = SinceMarker{time.Unix(0, 0), "latest"}
)
+324
View File
@@ -0,0 +1,324 @@
package s3
import (
"bytes"
"context"
"crypto/md5" //nolint:gosec // MD5 is required by the S3 protocol for Content-MD5 headers
"crypto/tls"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"heckel.io/ntfy/v2/log"
)
const (
tagS3Client = "s3_client"
)
// Client is a minimal S3-compatible client. It supports PutObject, GetObject, DeleteObjects,
// and ListObjectsV2 operations using AWS Signature V4 signing. The bucket and optional key prefix
// are fixed at construction time. All operations target the same bucket and prefix.
//
// The following IAM policy is required for AWS S3:
//
// {
// "Version": "2012-10-17",
// "Statement": [
// {
// "Effect": "Allow",
// "Action": [
// "s3:ListBucket",
// "s3:ListBucketMultipartUploads"
// ],
// "Resource": "arn:aws:s3:::BUCKET_NAME"
// },
// {
// "Effect": "Allow",
// "Action": [
// "s3:GetObject",
// "s3:PutObject",
// "s3:DeleteObject",
// "s3:AbortMultipartUpload"
// ],
// "Resource": "arn:aws:s3:::BUCKET_NAME/*"
// }
// ]
// }
//
// Fields must not be modified after the Client is passed to any method or goroutine.
type Client struct {
config *Config
http *http.Client
}
// New creates a new S3 client from the given Config.
func New(config *Config) *Client {
httpClient := config.HTTPClient
if httpClient == nil {
if config.DisableHTTP2 {
httpClient = newHTTP1Client()
} else {
httpClient = http.DefaultClient
}
}
return &Client{
config: config,
http: httpClient,
}
}
// PutObject uploads body to the given key. The key is automatically prefixed with the client's
// configured prefix.
//
// If untrustedLength is between 1 and 5 GB, the body is streamed directly to S3 via a
// single PUT request without buffering. The read is limited to untrustedLength bytes;
// any extra data in the body is ignored. If the body is shorter than claimed, the upload fails.
//
// Otherwise (untrustedLength <= 0 or > 5 GB), the first 5 MB are buffered to decide
// between a simple PUT and multipart upload.
//
// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html
// and https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html
func (c *Client) PutObject(ctx context.Context, key string, body io.Reader, untrustedLength int64) error {
if untrustedLength > 0 && untrustedLength <= maxSinglePutSize {
// Stream directly: Content-Length is known (but untrusted). LimitReader ensures we send at most
// untrustedLength bytes, and any extra data in body is ignored.
return c.putObject(ctx, key, io.LimitReader(body, untrustedLength), untrustedLength)
}
// Buffered path: read first 5 MB to decide simple vs multipart
first := make([]byte, partSize)
n, err := io.ReadFull(body, first)
if errors.Is(err, io.ErrUnexpectedEOF) || err == io.EOF {
return c.putObject(ctx, key, bytes.NewReader(first[:n]), int64(n))
} else if err != nil {
return fmt.Errorf("error reading object %s from client: %w", key, err)
}
return c.putObjectMultipart(ctx, key, io.MultiReader(bytes.NewReader(first), body))
}
// putObject uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD.
func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size int64) error {
log.Tag(tagS3Client).Debug("Uploading object %s (%d bytes)", key, size)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.config.ObjectURL(key), body)
if err != nil {
return fmt.Errorf("creating upload request object %s failed: %w", key, err)
}
req.ContentLength = size
c.signV4(req, unsignedPayload)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("uploading object %s failed: %w", key, err)
}
defer resp.Body.Close()
if !isHTTPSuccess(resp) {
return parseError(resp)
}
return nil
}
// GetObject downloads an object. The key is automatically prefixed with the client's configured
// prefix. The caller must close the returned ReadCloser.
//
// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html
func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int64, error) {
log.Tag(tagS3Client).Debug("Fetching object %s", key)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.ObjectURL(key), nil)
if err != nil {
return nil, 0, fmt.Errorf("error creating HTTP GET request for %s: %w", key, err)
}
c.signV4(req, emptyPayloadHash)
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("error fetching object %s: %w", key, err)
} else if !isHTTPSuccess(resp) {
err := parseError(resp)
resp.Body.Close()
return nil, 0, err
}
return resp.Body, resp.ContentLength, nil
}
// ListObjectsV2 returns all objects under the client's configured prefix by paginating through
// ListObjectsV2 results automatically. Keys in the returned objects have the prefix stripped,
// so they match the keys used with PutObject/GetObject/DeleteObjects. It stops after 10,000
// pages as a safety valve.
//
// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
func (c *Client) ListObjectsV2(ctx context.Context) ([]*Object, error) {
var all []*Object
var token string
for page := 0; page < maxPages; page++ {
result, err := c.listObjectsV2(ctx, token)
if err != nil {
return nil, err
}
for _, obj := range result.Contents {
var lastModified time.Time
if obj.LastModified != "" {
lastModified, _ = time.Parse(time.RFC3339, obj.LastModified)
}
all = append(all, &Object{
Key: c.config.StripPrefix(obj.Key),
Size: obj.Size,
LastModified: lastModified,
})
}
if !result.IsTruncated {
return all, nil
}
token = result.NextContinuationToken
}
return nil, fmt.Errorf("listing objects exceeded %d pages", maxPages)
}
// listObjectsV2 performs a single ListObjectsV2 request using the client's configured prefix.
func (c *Client) listObjectsV2(ctx context.Context, continuationToken string) (*listObjectsV2Result, error) {
if continuationToken == "" {
log.Tag(tagS3Client).Debug("Listing remote objects")
} else {
log.Tag(tagS3Client).Debug("Listing remote objects, continuing with token '%s'", continuationToken)
}
query := url.Values{"list-type": {"2"}}
if prefix := c.config.ListPrefix(); prefix != "" {
query.Set("prefix", prefix)
}
if continuationToken != "" {
query.Set("continuation-token", continuationToken)
}
respBody, err := c.do(ctx, "ListObjects", http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil)
if err != nil {
return nil, err
}
var result listObjectsV2Result
if err := xml.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal list object response: %w", err)
}
return &result, nil
}
// DeleteObjects removes multiple objects in a single batch request. Keys are automatically
// prefixed with the client's configured prefix. S3 supports up to 1000 keys per call; the
// caller is responsible for batching if needed.
//
// Even when S3 returns HTTP 200, individual keys may fail. If any per-key errors are present
// in the response, they are returned as a combined error.
//
// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
func (c *Client) DeleteObjects(ctx context.Context, keys []string) error {
// S3 DeleteObjects supports up to 1000 keys per call
for i := 0; i < len(keys); i += maxDeleteBatchSize {
end := i + maxDeleteBatchSize
if end > len(keys) {
end = len(keys)
}
if err := c.deleteObjects(ctx, keys[i:end]); err != nil {
return err
}
}
return nil
}
func (c *Client) deleteObjects(ctx context.Context, keys []string) error {
log.Tag(tagS3Client).Debug("Deleting %d object(s)", len(keys))
req := &deleteObjectsRequest{
Quiet: true,
}
for _, key := range keys {
req.Objects = append(req.Objects, &deleteObject{Key: c.config.ObjectKey(key)})
}
body, err := xml.Marshal(req)
if err != nil {
return fmt.Errorf("error marshalling XML for deleting objects: %w", err)
}
// Content-MD5 is required by the S3 protocol for DeleteObjects requests.
md5Sum := md5.Sum(body) //nolint:gosec
headers := map[string]string{
"Content-MD5": base64.StdEncoding.EncodeToString(md5Sum[:]),
}
reqURL := c.config.BucketURL() + "?delete"
respBody, err := c.do(ctx, "DeleteObjects", http.MethodPost, reqURL, body, headers)
if err != nil {
return fmt.Errorf("error deleting objects: %w", err)
}
// S3 may return HTTP 200 with per-key errors in the response body
var result deleteObjectsResult
if err := xml.Unmarshal(respBody, &result); err != nil {
return nil // If we can't parse, assume success (Quiet mode returns empty body on success)
}
if len(result.Errors) > 0 {
var msgs []string
for _, e := range result.Errors {
msgs = append(msgs, fmt.Sprintf("%s: %s", e.Key, e.Message))
}
return fmt.Errorf("error deleting objects, partial failure: %s", strings.Join(msgs, "; "))
}
return nil
}
// do creates a signed request, executes it, reads the response body, and checks for errors.
// If body is nil, the request is sent with an empty payload. If body is non-nil, it is sent
// with a computed SHA-256 payload hash and Content-Type: application/xml.
func (c *Client) do(ctx context.Context, op, method, reqURL string, body []byte, headers map[string]string) ([]byte, error) {
log.Tag(tagS3Client).Trace("Performing request %s %s %s (body: %d bytes)", op, method, reqURL, len(body))
var reader io.Reader
var hash string
if body != nil {
reader = bytes.NewReader(body)
hash = sha256Hex(body)
} else {
hash = emptyPayloadHash
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, reader)
if err != nil {
return nil, fmt.Errorf("s3: %s request: %w", op, err)
}
if body != nil {
req.ContentLength = int64(len(body))
req.Header.Set("Content-Type", "application/xml")
} else {
req.ContentLength = 0
}
for k, v := range headers {
req.Header.Set(k, v)
}
c.signV4(req, hash)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("s3: %s: %w", op, err)
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("s3: %s read: %w", op, err)
}
if !isHTTPSuccess(resp) {
return nil, parseErrorFromBytes(resp.StatusCode, respBody)
}
return respBody, nil
}
// newHTTP1Client creates an HTTP client that forces HTTP/1.1 by disabling HTTP/2
// ALPN negotiation. This works around HTTP/2 stream errors with some S3-compatible
// providers (e.g. DigitalOcean Spaces) that can cause non-retryable failures on
// streaming uploads when the server resets the stream mid-transfer.
// See https://github.com/rclone/rclone/issues/4673, https://github.com/golang/go/issues/42777
func newHTTP1Client() *http.Client {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
ForceAttemptHTTP2: false,
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
},
}
}
+71
View File
@@ -0,0 +1,71 @@
package s3
import (
"encoding/hex"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
// signV4 signs req in place using AWS Signature V4. payloadHash is the hex-encoded SHA-256
// of the request body, or the literal string "UNSIGNED-PAYLOAD" for streaming uploads.
//
// See https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html
func (c *Client) signV4(req *http.Request, hash string) {
now := time.Now().UTC()
datestamp := now.Format("20060102")
amzDate := now.Format("20060102T150405Z")
// Required headers
req.Header.Set("Host", c.config.HostHeader())
req.Header.Set("X-Amz-Date", amzDate)
req.Header.Set("X-Amz-Content-Sha256", hash)
// Canonical headers (all headers we set, sorted by lowercase key)
signedKeys := make([]string, 0, len(req.Header))
canonHeaders := make(map[string]string, len(req.Header))
for k := range req.Header {
lk := strings.ToLower(k)
signedKeys = append(signedKeys, lk)
canonHeaders[lk] = strings.TrimSpace(req.Header.Get(k))
}
sort.Strings(signedKeys)
signedHeadersStr := strings.Join(signedKeys, ";")
var chBuf strings.Builder
for _, k := range signedKeys {
chBuf.WriteString(k)
chBuf.WriteByte(':')
chBuf.WriteString(canonHeaders[k])
chBuf.WriteByte('\n')
}
// Canonical request
canonicalRequest := strings.Join([]string{
req.Method,
canonicalURI(req.URL),
canonicalQueryString(req.URL.Query()),
chBuf.String(),
signedHeadersStr,
hash,
}, "\n")
// String to sign
credentialScope := datestamp + "/" + c.config.Region + "/s3/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest))
// Signing key
signingKey := hmacSHA256(hmacSHA256(hmacSHA256(hmacSHA256(
[]byte("AWS4"+c.config.SecretKey), []byte(datestamp)),
[]byte(c.config.Region)),
[]byte("s3")),
[]byte("aws4_request"))
signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign)))
header := fmt.Sprintf(
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
c.config.AccessKey, credentialScope, signedHeadersStr, signature,
)
req.Header.Set("Authorization", header)
}
+187
View File
@@ -0,0 +1,187 @@
package s3
import (
"bytes"
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"heckel.io/ntfy/v2/log"
)
// AbortIncompleteUploads lists all in-progress multipart uploads and aborts those initiated
// before the given cutoff time. This cleans up orphaned upload parts from interrupted uploads.
//
// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html
// and https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html
func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) error {
uploads, err := c.listMultipartUploads(ctx)
if err != nil {
return err
}
for _, u := range uploads {
if !u.Initiated.IsZero() && u.Initiated.Before(cutoff) {
c.abortMultipartUpload(ctx, u.Key, u.UploadID)
}
}
return nil
}
// listMultipartUploads returns in-progress multipart uploads for the client's prefix.
// It paginates automatically, stopping after 10,000 pages as a safety valve.
func (c *Client) listMultipartUploads(ctx context.Context) ([]*multipartUpload, error) {
var all []*multipartUpload
var keyMarker, uploadIDMarker string
for page := 0; page < maxPages; page++ {
query := url.Values{"uploads": {""}}
if prefix := c.config.ListPrefix(); prefix != "" {
query.Set("prefix", prefix)
}
if keyMarker != "" {
query.Set("key-marker", keyMarker)
query.Set("upload-id-marker", uploadIDMarker)
}
respBody, err := c.do(ctx, "ListMultipartUploads", http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil)
if err != nil {
return nil, err
}
var result listMultipartUploadsResult
if err := xml.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("error unmarshalling multipart upload result: %w", err)
}
for _, u := range result.Uploads {
var initiated time.Time
if u.Initiated != "" {
initiated, _ = time.Parse(time.RFC3339, u.Initiated)
}
all = append(all, &multipartUpload{
Key: u.Key,
UploadID: u.UploadID,
Initiated: initiated,
})
}
if !result.IsTruncated {
return all, nil
}
keyMarker = result.NextKeyMarker
uploadIDMarker = result.NextUploadIDMarker
}
return nil, fmt.Errorf("error listing multipart uploads, exceeded %d pages", maxPages)
}
// abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up.
func (c *Client) abortMultipartUpload(ctx context.Context, key, uploadID string) {
log.Tag(tagS3Client).Info("Aborting multipart upload for object %s", key)
reqURL := fmt.Sprintf("%s?uploadId=%s", c.config.ObjectURL(key), url.QueryEscape(uploadID))
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
if err != nil {
return
}
c.signV4(req, emptyPayloadHash)
resp, err := c.http.Do(req)
if err != nil {
return
}
resp.Body.Close()
}
// putObjectMultipart uploads body using S3 multipart upload. It reads the body in partSize
// chunks, uploading each as a separate part. This allows uploading without knowing the total
// body size in advance.
func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Reader) error {
log.Tag(tagS3Client).Debug("Uploading multipart object %s", key)
// Step 1: Initiate multipart upload
uploadID, err := c.initiateMultipartUpload(ctx, key)
if err != nil {
return err
}
// Step 2: Upload parts
partNumber := 1
buf := make([]byte, partSize)
var parts []*completedPart
for {
n, err := io.ReadFull(body, buf)
if n > 0 {
etag, uploadErr := c.uploadPart(ctx, key, uploadID, partNumber, buf[:n])
if uploadErr != nil {
c.abortMultipartUpload(ctx, key, uploadID)
return uploadErr
}
parts = append(parts, &completedPart{
PartNumber: partNumber,
ETag: etag,
})
partNumber++
}
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
break
} else if err != nil {
c.abortMultipartUpload(ctx, key, uploadID)
return fmt.Errorf("error uploading object %s, reading from client failed: %w", key, err)
}
}
// Step 3: Complete multipart upload
return c.completeMultipartUpload(ctx, key, uploadID, parts)
}
// initiateMultipartUpload starts a new multipart upload and returns the upload ID.
func (c *Client) initiateMultipartUpload(ctx context.Context, key string) (string, error) {
respBody, err := c.do(ctx, "InitiateMultipartUpload", http.MethodPost, c.config.ObjectURL(key)+"?uploads", nil, nil)
if err != nil {
return "", err
}
var result initiateMultipartUploadResult
if err := xml.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("error unmarshalling initiate multipart upload response: %w", err)
}
return result.UploadID, nil
}
// uploadPart uploads a single part of a multipart upload and returns the ETag.
func (c *Client) uploadPart(ctx context.Context, key, uploadID string, partNumber int, data []byte) (string, error) {
log.Tag(tagS3Client).Debug("Uploading multipart part for object %s, part %d, size %d", key, partNumber, len(data))
reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.config.ObjectURL(key), partNumber, url.QueryEscape(uploadID))
req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("error creating multipart upload part request for object %s: %w", key, err)
}
req.ContentLength = int64(len(data))
c.signV4(req, unsignedPayload)
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("error uploading multipart part for object %s: %w", key, err)
}
defer resp.Body.Close()
if !isHTTPSuccess(resp) {
return "", parseError(resp)
}
return resp.Header.Get("ETag"), nil
}
// completeMultipartUpload finalizes a multipart upload with the given parts.
func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID string, parts []*completedPart) error {
log.Tag(tagS3Client).Debug("Completing multipart upload for object %s, %d parts", key, len(parts))
bodyBytes, err := xml.Marshal(&completeMultipartUploadRequest{Parts: parts})
if err != nil {
return fmt.Errorf("error marshalling complete multipart upload request: %w", err)
}
reqURL := fmt.Sprintf("%s?uploadId=%s", c.config.ObjectURL(key), url.QueryEscape(uploadID))
respBody, err := c.do(ctx, "CompleteMultipartUpload", http.MethodPost, reqURL, bodyBytes, nil)
if err != nil {
return err
}
// Check if the response contains an error (S3 can return 200 with an error body)
var errResp errorResponse
if xml.Unmarshal(respBody, &errResp) == nil && errResp.Code != "" {
return &errResp
}
return nil
}
+455
View File
@@ -0,0 +1,455 @@
package s3
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestParseURL_Success(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket/attachments?region=us-east-1")
require.Nil(t, err)
require.Equal(t, "my-bucket", cfg.Bucket)
require.Equal(t, "attachments", cfg.Prefix)
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)
}
func TestParseURL_NoPrefix(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1")
require.Nil(t, err)
require.Equal(t, "my-bucket", cfg.Bucket)
require.Equal(t, "", cfg.Prefix)
}
func TestParseURL_WithEndpoint(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket/prefix?region=us-east-1&endpoint=https://s3.example.com")
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)
}
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)
require.Equal(t, "s3.example.com", cfg.Endpoint)
}
func TestParseURL_NestedPrefix(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket/a/b/c?region=us-east-1")
require.Nil(t, err)
require.Equal(t, "my-bucket", cfg.Bucket)
require.Equal(t, "a/b/c", cfg.Prefix)
}
func TestParseURL_MissingRegion(t *testing.T) {
_, err := ParseURL("s3://AKID:SECRET@my-bucket")
require.Error(t, err)
require.Contains(t, err.Error(), "region")
}
func TestParseURL_MissingCredentials(t *testing.T) {
_, err := ParseURL("s3://my-bucket?region=us-east-1")
require.Error(t, err)
require.Contains(t, err.Error(), "access key")
}
func TestParseURL_MissingSecretKey(t *testing.T) {
_, err := ParseURL("s3://AKID@my-bucket?region=us-east-1")
require.Error(t, err)
require.Contains(t, err.Error(), "secret key")
}
func TestParseURL_WrongScheme(t *testing.T) {
_, err := ParseURL("http://AKID:SECRET@my-bucket?region=us-east-1")
require.Error(t, err)
require.Contains(t, err.Error(), "scheme")
}
func TestParseURL_EmptyBucket(t *testing.T) {
_, err := ParseURL("s3://AKID:SECRET@?region=us-east-1")
require.Error(t, err)
require.Contains(t, err.Error(), "bucket")
}
func TestParseURL_DisableHTTP2(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&disable_http2=true")
require.Nil(t, err)
require.True(t, cfg.DisableHTTP2)
}
func TestParseURL_DisableHTTP2_NotSet(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1")
require.Nil(t, err)
require.False(t, cfg.DisableHTTP2)
}
// --- Unit tests: URL construction ---
func TestConfig_BucketURL_PathStyle(t *testing.T) {
c := &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true}
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())
}
func TestConfig_ObjectURL_PathStyle(t *testing.T) {
c := &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: true}
require.Equal(t, "https://s3.example.com/my-bucket/prefix/obj", c.ObjectURL("obj"))
}
func TestConfig_ObjectURL_VirtualHosted(t *testing.T) {
c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: false}
require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com/prefix/obj", c.ObjectURL("obj"))
}
func TestConfig_HostHeader_PathStyle(t *testing.T) {
c := &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true}
require.Equal(t, "s3.example.com", c.HostHeader())
}
func TestConfig_HostHeader_VirtualHosted(t *testing.T) {
c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}
require.Equal(t, "my-bucket.s3.us-east-1.amazonaws.com", c.HostHeader())
}
func TestConfig_ObjectKey(t *testing.T) {
c := &Config{Prefix: "attachments"}
require.Equal(t, "attachments/file123", c.ObjectKey("file123"))
c2 := &Config{Prefix: ""}
require.Equal(t, "file123", c2.ObjectKey("file123"))
}
func TestConfig_ListPrefix(t *testing.T) {
c := &Config{Prefix: "attachments"}
require.Equal(t, "attachments/", c.ListPrefix())
c2 := &Config{Prefix: ""}
require.Equal(t, "", c2.ListPrefix())
}
// --- Integration tests using real S3 ---
func TestClient_PutGetObject(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// Put
err := client.PutObject(ctx, "test-key", strings.NewReader("hello world"), 0)
require.Nil(t, err)
// Get
reader, size, err := client.GetObject(ctx, "test-key")
require.Nil(t, err)
require.Equal(t, int64(11), size)
data, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello world", string(data))
}
func TestClient_GetObject_NotFound(t *testing.T) {
client := newTestClient(t)
_, _, err := client.GetObject(context.Background(), "nonexistent")
require.Error(t, err)
}
func TestClient_DeleteObjects(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// Put several objects
for i := 0; i < 5; i++ {
err := client.PutObject(ctx, fmt.Sprintf("del-%d", i), bytes.NewReader([]byte("data")), 0)
require.Nil(t, err)
}
waitForCount(t, client, 5)
// Delete some
err := client.DeleteObjects(ctx, []string{"del-1", "del-3"})
require.Nil(t, err)
waitForCount(t, client, 3)
// Verify deleted ones are gone
_, _, err = client.GetObject(ctx, "del-1")
require.Error(t, err)
_, _, err = client.GetObject(ctx, "del-3")
require.Error(t, err)
// Verify remaining ones are still there
for _, key := range []string{"del-0", "del-2", "del-4"} {
reader, _, err := client.GetObject(ctx, key)
require.Nil(t, err)
reader.Close()
}
}
func TestClient_ListObjects(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
err := client.PutObject(ctx, fmt.Sprintf("list-%d", i), bytes.NewReader([]byte("x")), 0)
require.Nil(t, err)
}
waitForCount(t, client, 3)
}
func TestClient_ListObjects_Pagination(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// Create 1010 objects in parallel (5 goroutines)
const total = 1010
const workers = 5
var wg sync.WaitGroup
errs := make(chan error, total)
for w := 0; w < workers; w++ {
wg.Add(1)
go func(start int) {
defer wg.Done()
for i := start; i < total; i += workers {
if err := client.PutObject(ctx, fmt.Sprintf("pg-%04d", i), bytes.NewReader([]byte("x")), 0); err != nil {
errs <- err
return
}
}
}(w)
}
wg.Wait()
close(errs)
for err := range errs {
require.Nil(t, err)
}
waitForCount(t, client, total)
}
func TestClient_PutObject_LargeBody(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// 1 MB object
data := make([]byte, 1024*1024)
for i := range data {
data[i] = byte(i % 256)
}
err := client.PutObject(ctx, "large", bytes.NewReader(data), 0)
require.Nil(t, err)
reader, size, err := client.GetObject(ctx, "large")
require.Nil(t, err)
require.Equal(t, int64(1024*1024), size)
got, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, data, got)
}
func TestClient_PutObject_ChunkedUpload(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// 12 MB object, exceeds 5 MB partSize, triggers multipart upload path
data := make([]byte, 12*1024*1024)
for i := range data {
data[i] = byte(i % 256)
}
err := client.PutObject(ctx, "multipart", bytes.NewReader(data), 0)
require.Nil(t, err)
reader, size, err := client.GetObject(ctx, "multipart")
require.Nil(t, err)
require.Equal(t, int64(12*1024*1024), size)
got, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, data, got)
}
func TestClient_PutObject_ExactPartSize(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// Exactly 5 MB (partSize), should use the simple put path (ReadFull succeeds fully)
data := make([]byte, 5*1024*1024)
for i := range data {
data[i] = byte(i % 256)
}
err := client.PutObject(ctx, "exact", bytes.NewReader(data), 0)
require.Nil(t, err)
reader, size, err := client.GetObject(ctx, "exact")
require.Nil(t, err)
require.Equal(t, int64(5*1024*1024), size)
got, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, data, got)
}
func TestClient_PutObject_StreamingExactLength(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// untrustedLength matches body exactly — streams directly via putObject
err := client.PutObject(ctx, "stream-exact", strings.NewReader("hello world"), 11)
require.Nil(t, err)
reader, size, err := client.GetObject(ctx, "stream-exact")
require.Nil(t, err)
require.Equal(t, int64(11), size)
got, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello world", string(got))
}
func TestClient_PutObject_StreamingBodyLongerThanClaimed(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored
err := client.PutObject(ctx, "stream-long", strings.NewReader("hello world"), 5)
require.Nil(t, err)
reader, size, err := client.GetObject(ctx, "stream-long")
require.Nil(t, err)
require.Equal(t, int64(5), size)
got, err := io.ReadAll(reader)
reader.Close()
require.Nil(t, err)
require.Equal(t, "hello", string(got))
}
func TestClient_PutObject_StreamingBodyShorterThanClaimed(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
// Body has 5 bytes, but we claim 100 — should fail
err := client.PutObject(ctx, "stream-short", strings.NewReader("hello"), 100)
require.Error(t, err)
// Object should not exist
_, _, err = client.GetObject(ctx, "stream-short")
require.Error(t, err)
}
func TestClient_PutObject_NestedKey(t *testing.T) {
client := newTestClient(t)
ctx := context.Background()
err := client.PutObject(ctx, "deep/nested/prefix/file.txt", strings.NewReader("nested"), 0)
require.Nil(t, err)
reader, _, err := client.GetObject(ctx, "deep/nested/prefix/file.txt")
require.Nil(t, err)
data, _ := io.ReadAll(reader)
reader.Close()
require.Equal(t, "nested", string(data))
}
func newTestClient(t *testing.T) *Client {
t.Helper()
s3URL := os.Getenv("NTFY_TEST_S3_URL")
if s3URL == "" {
t.Skip("NTFY_TEST_S3_URL not set")
}
cfg, err := ParseURL(s3URL)
require.Nil(t, err)
// Use per-test prefix to isolate objects between tests
if cfg.Prefix != "" {
cfg.Prefix = cfg.Prefix + "/testpkg-s3/" + t.Name()
} else {
cfg.Prefix = "testpkg-s3/" + t.Name()
}
client := New(cfg)
deleteAllObjects(t, client)
t.Cleanup(func() { deleteAllObjects(t, client) })
return client
}
func deleteAllObjects(t *testing.T, client *Client) {
t.Helper()
for i := 0; i < 60; i++ {
objects, err := client.ListObjectsV2(context.Background())
require.Nil(t, err)
if len(objects) == 0 {
return
}
keys := make([]string, len(objects))
for j, obj := range objects {
keys[j] = obj.Key
}
require.Nil(t, client.DeleteObjects(context.Background(), keys))
time.Sleep(500 * time.Millisecond)
}
t.Fatal("timed out waiting for bucket to be empty")
}
func waitForCount(t *testing.T, client *Client, expected int) {
t.Helper()
for i := 0; i < 60; i++ {
objects, err := client.ListObjectsV2(context.Background())
require.Nil(t, err)
if len(objects) == expected {
return
}
time.Sleep(500 * time.Millisecond)
}
objects, _ := client.ListObjectsV2(context.Background())
t.Fatalf("timed out waiting for %d objects, got %d", expected, len(objects))
}
+171
View File
@@ -0,0 +1,171 @@
package s3
import (
"encoding/xml"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// 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
Prefix string
Region string
AccessKey string
SecretKey string
DisableHTTP2 bool // Force HTTP/1.1 to work around HTTP/2 issues with some S3-compatible providers
HTTPClient *http.Client // if nil, a default client is created (respecting DisableHTTP2)
}
// BucketURL returns the base URL for bucket-level operations.
func (c *Config) BucketURL() string {
scheme := "https"
if c.Scheme != "" {
scheme = c.Scheme
}
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.
func (c *Config) HostHeader() string {
if c.PathStyle {
return c.Endpoint
}
return c.Bucket + "." + c.Endpoint
}
// ListPrefix returns the prefix to use in ListObjectsV2 requests,
// with a trailing slash so that only objects under the prefix directory are returned.
func (c *Config) ListPrefix() string {
if c.Prefix != "" {
return c.Prefix + "/"
}
return ""
}
// StripPrefix removes the configured prefix from a key returned by ListObjectsV2,
// so keys match what was passed to PutObject/GetObject/DeleteObjects.
func (c *Config) StripPrefix(key string) string {
if c.Prefix != "" {
return strings.TrimPrefix(key, c.Prefix+"/")
}
return key
}
// ObjectKey prepends the configured prefix to the given key.
func (c *Config) ObjectKey(key string) string {
if c.Prefix != "" {
return c.Prefix + "/" + key
}
return key
}
// ObjectURL returns the full URL for an object, automatically prepending the configured prefix.
func (c *Config) ObjectURL(key string) string {
u, _ := url.JoinPath(c.BucketURL(), c.ObjectKey(key))
return u
}
// Object represents an S3 object returned by list operations.
type Object struct {
Key string
Size int64
LastModified time.Time
}
// errorResponse is returned when S3 responds with a non-2xx status code.
type errorResponse struct {
StatusCode int
Code string `xml:"Code"`
Message string `xml:"Message"`
Body string `xml:"-"` // raw response body
}
func (e *errorResponse) Error() string {
if e.Code != "" {
return fmt.Sprintf("s3: %s (HTTP %d): %s", e.Code, e.StatusCode, e.Message)
}
return fmt.Sprintf("s3: HTTP %d: %s", e.StatusCode, e.Body)
}
// listObjectsV2Result is the XML response from S3 ListObjectsV2
type listObjectsV2Result struct {
Contents []*listObject `xml:"Contents"`
IsTruncated bool `xml:"IsTruncated"`
NextContinuationToken string `xml:"NextContinuationToken"`
}
type listObject struct {
Key string `xml:"Key"`
Size int64 `xml:"Size"`
LastModified string `xml:"LastModified"`
}
// deleteObjectsRequest is the XML request body for S3 DeleteObjects
type deleteObjectsRequest struct {
XMLName xml.Name `xml:"Delete"`
Quiet bool `xml:"Quiet"`
Objects []*deleteObject `xml:"Object"`
}
type deleteObject struct {
Key string `xml:"Key"`
}
// deleteObjectsResult is the XML response from S3 DeleteObjects
type deleteObjectsResult struct {
Errors []*deleteError `xml:"Error"`
}
type deleteError struct {
Key string `xml:"Key"`
Code string `xml:"Code"`
Message string `xml:"Message"`
}
// listMultipartUploadsResult is the XML response from S3 listMultipartUploads
type listMultipartUploadsResult struct {
Uploads []*listUpload `xml:"Upload"`
IsTruncated bool `xml:"IsTruncated"`
NextKeyMarker string `xml:"NextKeyMarker"`
NextUploadIDMarker string `xml:"NextUploadIdMarker"`
}
type listUpload struct {
Key string `xml:"Key"`
UploadID string `xml:"UploadId"`
Initiated string `xml:"Initiated"`
}
// multipartUpload represents an in-progress multipart upload returned by listMultipartUploads.
type multipartUpload struct {
Key string
UploadID string
Initiated time.Time
}
// initiateMultipartUploadResult is the XML response from S3 InitiateMultipartUpload
type initiateMultipartUploadResult struct {
UploadID string `xml:"UploadId"`
}
// completeMultipartUploadRequest is the XML request body for S3 CompleteMultipartUpload
type completeMultipartUploadRequest struct {
XMLName xml.Name `xml:"CompleteMultipartUpload"`
Parts []*completedPart `xml:"Part"`
}
// completedPart represents a successfully uploaded part for CompleteMultipartUpload
type completedPart struct {
PartNumber int `xml:"PartNumber"`
ETag string `xml:"ETag"`
}
+194
View File
@@ -0,0 +1,194 @@
package s3
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
)
const (
// SHA-256 hash of the empty string, used as the payload hash for bodiless requests
emptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
// Sent as the payload hash for streaming uploads where the body is not buffered in memory
unsignedPayload = "UNSIGNED-PAYLOAD"
// maxResponseBytes caps the size of S3 response bodies we read into memory
maxResponseBytes = 2 * 1024 * 1024
// partSize is the size of each part for multipart uploads (5 MB). This is also the threshold
// above which PutObject switches from a simple PUT to multipart upload. S3 requires a minimum
// part size of 5 MB for all parts except the last.
partSize = 5 * 1024 * 1024
// maxSinglePutSize is the maximum size for a single PUT upload (5 GB).
// Objects larger than this must use multipart upload.
maxSinglePutSize = 5 * 1024 * 1024 * 1024
// maxPages is the max number of pages to iterate through when listing objects
maxPages = 500
// maxDeleteBatchSize is the maximum number of keys per S3 DeleteObjects call
maxDeleteBatchSize = 1000
)
// ParseURL parses an S3 URL of the form:
//
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
//
// When endpoint is specified, path-style addressing is enabled automatically.
// When disable_http2=true is set, the client forces HTTP/1.1 to work around
// HTTP/2 stream errors with some S3-compatible providers (e.g. DigitalOcean Spaces).
func ParseURL(s3URL string) (*Config, error) {
u, err := url.Parse(s3URL)
if err != nil {
return nil, fmt.Errorf("s3: invalid URL: %w", err)
}
if u.Scheme != "s3" {
return nil, fmt.Errorf("s3: URL scheme must be 's3', got '%s'", u.Scheme)
}
if u.Host == "" {
return nil, fmt.Errorf("s3: bucket name must be specified as host")
}
bucket := u.Host
prefix := strings.TrimPrefix(u.Path, "/")
accessKey := u.User.Username()
secretKey, _ := u.User.Password()
if accessKey == "" || secretKey == "" {
return nil, fmt.Errorf("s3: access key and secret key must be specified in URL")
}
region := u.Query().Get("region")
if region == "" {
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: 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,
Prefix: prefix,
Region: region,
AccessKey: accessKey,
SecretKey: secretKey,
DisableHTTP2: disableHTTP2,
}, nil
}
// parseError reads an S3 error response and returns an *errorResponse.
func parseError(resp *http.Response) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return fmt.Errorf("error reading S3 error response: %w", err)
}
return parseErrorFromBytes(resp.StatusCode, body)
}
func parseErrorFromBytes(statusCode int, body []byte) error {
errResp := &errorResponse{
StatusCode: statusCode,
Body: string(body),
}
// Try to parse XML error; if it fails, we still have StatusCode and Body
_ = xml.Unmarshal(body, errResp)
return errResp
}
// canonicalURI returns the URI-encoded path for the canonical request. Each path segment is
// percent-encoded per RFC 3986; forward slashes are preserved.
func canonicalURI(u *url.URL) string {
p := u.Path
if p == "" {
return "/"
}
segments := strings.Split(p, "/")
for i, seg := range segments {
segments[i] = uriEncode(seg)
}
return strings.Join(segments, "/")
}
// canonicalQueryString builds the query string for the canonical request. Keys and values
// are URI-encoded per RFC 3986 (using %20, not +) and sorted lexically by key.
func canonicalQueryString(values url.Values) string {
if len(values) == 0 {
return ""
}
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
var pairs []string
for _, k := range keys {
ek := uriEncode(k)
vs := make([]string, len(values[k]))
copy(vs, values[k])
sort.Strings(vs)
for _, v := range vs {
pairs = append(pairs, ek+"="+uriEncode(v))
}
}
return strings.Join(pairs, "&")
}
// uriEncode percent-encodes a string per RFC 3986, encoding everything except unreserved
// characters (A-Z a-z 0-9 - _ . ~).
func uriEncode(s string) string {
var buf strings.Builder
for i := 0; i < len(s); i++ {
b := s[i]
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') ||
b == '-' || b == '_' || b == '.' || b == '~' {
buf.WriteByte(b)
} else {
fmt.Fprintf(&buf, "%%%02X", b)
}
}
return buf.String()
}
func isHTTPSuccess(resp *http.Response) bool {
return resp.StatusCode/100 == 2
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
+181
View File
@@ -0,0 +1,181 @@
package s3
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
func TestURIEncode(t *testing.T) {
// Unreserved characters are not encoded
require.Equal(t, "abcdefghijklmnopqrstuvwxyz", uriEncode("abcdefghijklmnopqrstuvwxyz"))
require.Equal(t, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", uriEncode("ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
require.Equal(t, "0123456789", uriEncode("0123456789"))
require.Equal(t, "-_.~", uriEncode("-_.~"))
// Spaces use %20, not +
require.Equal(t, "hello%20world", uriEncode("hello world"))
// Slashes are encoded (canonicalURI handles slash splitting separately)
require.Equal(t, "a%2Fb", uriEncode("a/b"))
// Special characters
require.Equal(t, "%2B", uriEncode("+"))
require.Equal(t, "%3D", uriEncode("="))
require.Equal(t, "%26", uriEncode("&"))
require.Equal(t, "%40", uriEncode("@"))
require.Equal(t, "%23", uriEncode("#"))
// Mixed
require.Equal(t, "test~file-name_1.txt", uriEncode("test~file-name_1.txt"))
require.Equal(t, "key%20with%20spaces%2Fand%2Fslashes", uriEncode("key with spaces/and/slashes"))
// Empty string
require.Equal(t, "", uriEncode(""))
}
func TestCanonicalURI(t *testing.T) {
// Simple path
u, _ := url.Parse("https://example.com/bucket/key")
require.Equal(t, "/bucket/key", canonicalURI(u))
// Root path
u, _ = url.Parse("https://example.com/")
require.Equal(t, "/", canonicalURI(u))
// Empty path
u, _ = url.Parse("https://example.com")
require.Equal(t, "/", canonicalURI(u))
// Path with special characters
u, _ = url.Parse("https://example.com/bucket/key%20with%20spaces")
require.Equal(t, "/bucket/key%20with%20spaces", canonicalURI(u))
// Nested path
u, _ = url.Parse("https://example.com/bucket/a/b/c/file.txt")
require.Equal(t, "/bucket/a/b/c/file.txt", canonicalURI(u))
}
func TestCanonicalQueryString(t *testing.T) {
// Multiple keys sorted alphabetically
vals := url.Values{
"prefix": {"test/"},
"list-type": {"2"},
}
require.Equal(t, "list-type=2&prefix=test%2F", canonicalQueryString(vals))
// Empty values
require.Equal(t, "", canonicalQueryString(url.Values{}))
// Single key
require.Equal(t, "key=value", canonicalQueryString(url.Values{"key": {"value"}}))
// Key with multiple values (sorted)
vals = url.Values{"key": {"b", "a"}}
require.Equal(t, "key=a&key=b", canonicalQueryString(vals))
// Keys requiring encoding
vals = url.Values{"continuation-token": {"abc+def"}}
require.Equal(t, "continuation-token=abc%2Bdef", canonicalQueryString(vals))
}
func TestSHA256Hex(t *testing.T) {
// SHA-256 of empty string
require.Equal(t, emptyPayloadHash, sha256Hex([]byte("")))
// SHA-256 of known value
require.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", sha256Hex([]byte("hello")))
}
func TestHmacSHA256(t *testing.T) {
// Known test vector: HMAC-SHA256("key", "message")
result := hmacSHA256([]byte("key"), []byte("message"))
require.Len(t, result, 32) // SHA-256 produces 32 bytes
require.NotEqual(t, make([]byte, 32), result)
// Same inputs should produce same output
result2 := hmacSHA256([]byte("key"), []byte("message"))
require.Equal(t, result, result2)
// Different inputs should produce different output
result3 := hmacSHA256([]byte("different-key"), []byte("message"))
require.NotEqual(t, result, result3)
}
func TestSignV4_SetsRequiredHeaders(t *testing.T) {
c := &Client{config: &Config{
AccessKey: "AKID",
SecretKey: "SECRET",
Region: "us-east-1",
Endpoint: "s3.us-east-1.amazonaws.com",
Bucket: "my-bucket",
}}
req, _ := http.NewRequest(http.MethodGet, "https://my-bucket.s3.us-east-1.amazonaws.com/test-key", nil)
c.signV4(req, emptyPayloadHash)
// All required SigV4 headers must be set
require.NotEmpty(t, req.Header.Get("Host"))
require.NotEmpty(t, req.Header.Get("X-Amz-Date"))
require.Equal(t, emptyPayloadHash, req.Header.Get("X-Amz-Content-Sha256"))
// Authorization header must have correct format
auth := req.Header.Get("Authorization")
require.Contains(t, auth, "AWS4-HMAC-SHA256")
require.Contains(t, auth, "Credential=AKID/")
require.Contains(t, auth, "/us-east-1/s3/aws4_request")
require.Contains(t, auth, "SignedHeaders=")
require.Contains(t, auth, "Signature=")
}
func TestSignV4_UnsignedPayload(t *testing.T) {
c := &Client{config: &Config{
AccessKey: "AKID",
SecretKey: "SECRET",
Region: "us-east-1",
Endpoint: "s3.us-east-1.amazonaws.com",
Bucket: "my-bucket",
}}
req, _ := http.NewRequest(http.MethodPut, "https://my-bucket.s3.us-east-1.amazonaws.com/test-key", nil)
c.signV4(req, unsignedPayload)
require.Equal(t, unsignedPayload, req.Header.Get("X-Amz-Content-Sha256"))
}
func TestSignV4_DifferentRegions(t *testing.T) {
c1 := &Client{config: &Config{AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "b"}}
c2 := &Client{config: &Config{AccessKey: "AKID", SecretKey: "SECRET", Region: "eu-west-1", Endpoint: "s3.eu-west-1.amazonaws.com", Bucket: "b"}}
req1, _ := http.NewRequest(http.MethodGet, "https://b.s3.us-east-1.amazonaws.com/key", nil)
c1.signV4(req1, emptyPayloadHash)
req2, _ := http.NewRequest(http.MethodGet, "https://b.s3.eu-west-1.amazonaws.com/key", nil)
c2.signV4(req2, emptyPayloadHash)
// Different regions should produce different signatures
require.NotEqual(t, req1.Header.Get("Authorization"), req2.Header.Get("Authorization"))
}
func TestParseError_XMLResponse(t *testing.T) {
xmlBody := []byte(`<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message></Error>`)
err := parseErrorFromBytes(404, xmlBody)
var errResp *errorResponse
require.ErrorAs(t, err, &errResp)
require.Equal(t, 404, errResp.StatusCode)
require.Equal(t, "NoSuchKey", errResp.Code)
require.Equal(t, "The specified key does not exist.", errResp.Message)
}
func TestParseError_NonXMLResponse(t *testing.T) {
err := parseErrorFromBytes(500, []byte("internal server error"))
var errResp *errorResponse
require.ErrorAs(t, err, &errResp)
require.Equal(t, 500, errResp.StatusCode)
require.Equal(t, "", errResp.Code) // XML parsing failed, no code
require.Contains(t, errResp.Body, "internal server error")
}
+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"
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/ban"
)
// TestServer_BanFeed_WritesOffenderToFile is the end-to-end wiring test: a rejected request flows
// through s.handle -> the error responder -> s.ban.Record, and once the offender's prefix
// breaches, its ban line lands in the ban file (flushed on Close).
func TestServer_BanFeed_WritesOffenderToFile(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
conf := newTestConfig(t, "")
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = 1 // capacity 1: the 2nd rejection breaches
conf.BanWeights = ban.Weights{"*": 1} // any 4xx counts one strike
s := newTestServer(t, conf)
require.NotNil(t, s.ban)
// A delayed message with caching disabled is a deterministic 400 (errHTTPBadRequestDelayNoCache).
// request() sends from RemoteAddr 9.9.9.9.
reject := map[string]string{"Cache": "no", "In": "30 min"}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", "", reject)
require.Equal(t, 400, response.Code)
}
// Writes are async; Close flushes the buffer. The offender's prefix must be in the feed exactly
// once (throttled to one line per window).
s.ban.Close()
data, err := os.ReadFile(banFile)
require.NoError(t, err)
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
require.Len(t, lines, 1)
require.Contains(t, lines[0], " 9.9.9.9 9.9.9.9/32 400 ") // <ip> <prefix> <http-code> <ntfy-code>
}
// TestServer_BanFeed_DisabledByDefault verifies the feature is off with no ban file: s.ban is
// nil and the error path skips it (guarded), so a rejected request must not panic.
func TestServer_BanFeed_DisabledByDefault(t *testing.T) {
conf := newTestConfig(t, "") // no BanFile
s := newTestServer(t, conf)
require.Nil(t, s.ban)
reject := map[string]string{"Cache": "no", "In": "30 min"}
response := request(t, s, "PUT", "/mytopic", "", reject) // guarded callsite, no Record
require.Equal(t, 400, response.Code)
}
+82 -20
View File
@@ -10,6 +10,7 @@ import (
"text/template"
"time"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/user"
)
@@ -20,6 +21,7 @@ const (
DefaultCacheBatchTimeout = time.Duration(0)
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
DefaultManagerInterval = time.Minute
DefaultManagerBatchSize = 30000
DefaultDelayedSenderInterval = 10 * time.Second
DefaultMessageDelayMin = 10 * time.Second
DefaultMessageDelayMax = 3 * 24 * time.Hour
@@ -41,16 +43,46 @@ const (
DefaultWebPushExpiryDuration = 60 * 24 * time.Hour
)
// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
const (
DefaultBanWindow = 10 * time.Minute
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned
)
// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
// and everything else defaults to weight 1 (no "*" rule needed; see BanWeights.WeightFor).
var DefaultBanWeights = []string{
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
}
// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with
// the errHTTP definitions instead of being duplicated as string literals.
func banWeight(err *errHTTP, weight int) string {
return fmt.Sprintf("%d:%d", err.Code, weight)
}
// Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message
// - total topic limit: max number of topics overall
// - various attachment limits
const (
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
DefaultTotalTopicLimit = 15000
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
DefaultAttachmentExpiryDuration = 3 * time.Hour
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
DefaultTotalTopicLimit = 15000
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
DefaultAttachmentExpiryDuration = 3 * time.Hour
DefaultAttachmentOrphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads
// DefaultMessagePollSizeLimit caps what one cache replay returns per topic. It is a backstop
// against a single request materializing an entire topic cache, not a tunable: on ntfy.sh it
// would fire on 2 of ~98k cached topics. See docs/subscribe/api.md#replay-limits.
DefaultMessagePollSizeLimit = 10 * 1024 * 1024
// messageTitleSizeLimit and messageTagsSizeLimit cap two publisher-controlled fields that
// otherwise have no limit of their own. Sized off ntfy.sh's own cache: title p999 is 212 bytes
// (16 of ~3M messages exceed 1 KB), tags p999 is 244 (197 exceed 512).
messageTitleSizeLimit = 1024
messageTagsSizeLimit = 512
)
// Defines all per-visitor limits
@@ -66,7 +98,9 @@ const (
DefaultVisitorMessageDailyLimit = 0
DefaultVisitorEmailLimitBurst = 16
DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorAccountCreationLimitBurst = 3
DefaultVisitorTopicCreationLimitBurst = 100
DefaultVisitorTopicCreationLimitReplenish = time.Minute
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
DefaultVisitorAuthFailureLimitBurst = 30
DefaultVisitorAuthFailureLimitReplenish = time.Minute
@@ -88,7 +122,6 @@ var (
// Config is the main config struct for the application. Use New to instantiate a default config struct.
type Config struct {
File string // Config file, only used for testing
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
BaseURL string
ListenHTTP string
ListenHTTPS string
@@ -96,6 +129,8 @@ type Config struct {
ListenUnixMode fs.FileMode
KeyFile string
CertFile string
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
@@ -105,18 +140,22 @@ type Config struct {
AuthFile string
AuthStartupQueries string
AuthDefault user.Permission
AuthUsers []*user.User
AuthUsers []*user.User `hash:"-"`
AuthAccess map[string][]*user.Grant
AuthTokens map[string][]*user.Token
AuthTokens map[string][]*user.Token `hash:"-"`
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
AttachmentExpiryDuration time.Duration
AttachmentOrphanGracePeriod time.Duration
TemplateDir string // Directory to load named templates from
KeepaliveInterval time.Duration
ManagerInterval time.Duration
ManagerBatchSize int
DisallowedTopics []string
WebRoot string // empty to disable
DelayedSenderInterval time.Duration
@@ -124,27 +163,28 @@ type Config struct {
FirebasePollInterval time.Duration
FirebaseQuotaExceededPenaltyDuration time.Duration
UpstreamBaseURL string
UpstreamAccessToken string
UpstreamAccessToken string `hash:"-"`
SMTPSenderAddr string
SMTPSenderUser string
SMTPSenderPass string
SMTPSenderPass string `hash:"-"`
SMTPSenderFrom string
SMTPSenderVerify bool
SMTPServerListen string
SMTPServerDomain string
SMTPServerAddrPrefix string
TwilioAccount string
TwilioAuthToken string
TwilioAuthToken string `hash:"-"`
TwilioPhoneNumber string
TwilioCallsBaseURL string
TwilioVerifyBaseURL string
TwilioVerifyService string
TwilioCallFormat *template.Template
MetricsEnable bool
MetricsListenHTTP string
ProfileListenHTTP string
MessageDelayMin time.Duration
MessageDelayMax time.Duration
MessageSizeLimit int
MessagePollSizeLimit int64
TotalTopicLimit int
TotalAttachmentSizeLimit int64
VisitorSubscriptionLimit int
@@ -156,6 +196,8 @@ type Config struct {
VisitorMessageDailyLimit int
VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration
VisitorTopicCreationLimitBurst int // Burst of new topic creations per visitor
VisitorTopicCreationLimitReplenish time.Duration // Interval at which topic-creation tokens are refilled
VisitorAccountCreationLimitBurst int
VisitorAccountCreationLimitReplenish time.Duration
VisitorAuthFailureLimitBurst int
@@ -167,8 +209,8 @@ type Config struct {
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
StripeSecretKey string
StripeWebhookKey string
StripeSecretKey string `hash:"-"`
StripeWebhookKey string `hash:"-"`
StripePriceCacheDuration time.Duration
BillingContact string
EnableSignup bool // Enable creation of accounts via API and UI
@@ -177,23 +219,26 @@ type Config struct {
EnableReservations bool // Allow users with role "user" to own/reserve topics
EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
WebPushPrivateKey string
WebPushPrivateKey string `hash:"-"`
WebPushPublicKey string
WebPushFile string
WebPushEmailAddress string
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
BanThreshold int // Abuse ban-feed: weighted strikes per window before a prefix is banned
BanWeights ban.Weights // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor)
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
}
// NewConfig instantiates a default new server config
func NewConfig() *Config {
return &Config{
File: DefaultConfigFile, // Only used for testing
DatabaseURL: "",
BaseURL: "",
ListenHTTP: DefaultListenHTTP,
ListenHTTPS: "",
@@ -201,6 +246,7 @@ func NewConfig() *Config {
ListenUnixMode: 0,
KeyFile: "",
CertFile: "",
DatabaseURL: "",
FirebaseKeyFile: "",
CacheFile: "",
CacheDuration: DefaultCacheDuration,
@@ -212,13 +258,17 @@ func NewConfig() *Config {
AuthDefault: user.PermissionReadWrite,
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled,
AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval,
AttachmentCacheDir: "",
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
AttachmentOrphanGracePeriod: DefaultAttachmentOrphanGracePeriod,
TemplateDir: DefaultTemplateDir,
KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval,
ManagerBatchSize: DefaultManagerBatchSize,
DisallowedTopics: DefaultDisallowedTopics,
WebRoot: "/",
DelayedSenderInterval: DefaultDelayedSenderInterval,
@@ -231,6 +281,7 @@ func NewConfig() *Config {
SMTPSenderUser: "",
SMTPSenderPass: "",
SMTPSenderFrom: "",
SMTPSenderVerify: false,
SMTPServerListen: "",
SMTPServerDomain: "",
SMTPServerAddrPrefix: "",
@@ -242,6 +293,7 @@ func NewConfig() *Config {
TwilioVerifyService: "",
TwilioCallFormat: nil,
MessageSizeLimit: DefaultMessageSizeLimit,
MessagePollSizeLimit: DefaultMessagePollSizeLimit,
MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax,
TotalTopicLimit: DefaultTotalTopicLimit,
@@ -256,6 +308,8 @@ func NewConfig() *Config {
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
VisitorTopicCreationLimitBurst: DefaultVisitorTopicCreationLimitBurst,
VisitorTopicCreationLimitReplenish: DefaultVisitorTopicCreationLimitReplenish,
VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
@@ -280,6 +334,10 @@ func NewConfig() *Config {
WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BanFile: "",
BanWindow: DefaultBanWindow,
BanThreshold: DefaultBanThreshold,
BanWeights: nil,
BuildVersion: "",
BuildDate: "",
BuildCommit: "",
@@ -296,6 +354,10 @@ func (c *Config) Hash() string {
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldName := t.Field(i).Name
// Secrets must not feed the hash
if t.Field(i).Tag.Get("hash") == "-" {
continue
}
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
if b, err := json.Marshal(field.Interface()); err == nil {
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
+22
View File
@@ -3,6 +3,7 @@ package server_test
import (
"github.com/stretchr/testify/assert"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"testing"
)
@@ -11,3 +12,24 @@ func TestConfig_New(t *testing.T) {
assert.Equal(t, ":80", c.ListenHTTP)
assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)
}
func TestConfig_HashExcludesSecrets(t *testing.T) {
// The config hash is served to browsers (ConfigHash, for webapp change detection), so
// secret material must not feed it: a weak secret would otherwise be offline-brute-forceable
// against a publicly visible hash.
conf1 := server.NewConfig()
conf2 := server.NewConfig()
conf2.StripeSecretKey = "sk_live_topsecret"
conf2.StripeWebhookKey = "whsec_topsecret"
conf2.TwilioAuthToken = "twilio-auth-token"
conf2.UpstreamAccessToken = "tk_upstream"
conf2.WebPushPrivateKey = "web-push-private-key"
conf2.SMTPSenderPass = "hunter2"
conf2.AuthUsers = []*user.User{{Name: "phil", Hash: "$2a$10$somebcrypthash"}}
conf2.AuthTokens = map[string][]*user.Token{"phil": {{Value: "tk_secrettoken"}}}
assert.Equal(t, conf1.Hash(), conf2.Hash())
// Non-secret fields must still change the hash
conf3 := server.NewConfig()
conf3.BaseURL = "https://ntfy.example.com"
assert.NotEqual(t, conf1.Hash(), conf3.Hash())
}
+14 -2
View File
@@ -136,12 +136,21 @@ var (
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, define, or block", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
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}
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTitleTooLarge = &errHTTP{40057, http.StatusBadRequest, "invalid request: title is too large", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", 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}
@@ -151,6 +160,8 @@ var (
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
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}
@@ -160,11 +171,12 @@ 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
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
-127
View File
@@ -1,127 +0,0 @@
package server
import (
"errors"
"fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"io"
"os"
"path/filepath"
"regexp"
"sync"
)
var (
fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength))
errInvalidFileID = errors.New("invalid file ID")
errFileExists = errors.New("file exists")
)
type fileCache struct {
dir string
totalSizeCurrent int64
totalSizeLimit int64
mu sync.Mutex
}
func newFileCache(dir string, totalSizeLimit int64) (*fileCache, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
size, err := dirSize(dir)
if err != nil {
return nil, err
}
return &fileCache{
dir: dir,
totalSizeCurrent: size,
totalSizeLimit: totalSizeLimit,
}, nil
}
func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) {
if !fileIDRegex.MatchString(id) {
return 0, errInvalidFileID
}
log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment")
file := filepath.Join(c.dir, id)
if _, err := os.Stat(file); err == nil {
return 0, errFileExists
}
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return 0, err
}
defer f.Close()
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()))
limitWriter := util.NewLimitWriter(f, limiters...)
size, err := io.Copy(limitWriter, in)
if err != nil {
os.Remove(file)
return 0, err
}
if err := f.Close(); err != nil {
os.Remove(file)
return 0, err
}
c.mu.Lock()
c.totalSizeCurrent += size
mset(metricAttachmentsTotalSize, c.totalSizeCurrent)
c.mu.Unlock()
return size, nil
}
func (c *fileCache) Remove(ids ...string) error {
for _, id := range ids {
if !fileIDRegex.MatchString(id) {
return errInvalidFileID
}
log.Tag(tagFileCache).Field("message_id", id).Debug("Deleting attachment")
file := filepath.Join(c.dir, id)
if err := os.Remove(file); err != nil {
log.Tag(tagFileCache).Field("message_id", id).Err(err).Debug("Error deleting attachment")
}
}
size, err := dirSize(c.dir)
if err != nil {
return err
}
c.mu.Lock()
c.totalSizeCurrent = size
c.mu.Unlock()
mset(metricAttachmentsTotalSize, size)
return nil
}
func (c *fileCache) Size() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.totalSizeCurrent
}
func (c *fileCache) Remaining() int64 {
c.mu.Lock()
defer c.mu.Unlock()
remaining := c.totalSizeLimit - c.totalSizeCurrent
if remaining < 0 {
return 0
}
return remaining
}
func dirSize(dir string) (int64, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, err
}
var size int64
for _, e := range entries {
info, err := e.Info()
if err != nil {
return 0, err
}
size += info.Size()
}
return size, nil
}
-76
View File
@@ -1,76 +0,0 @@
package server
import (
"bytes"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/util"
"os"
"strings"
"testing"
)
var (
oneKilobyteArray = make([]byte, 1024)
)
func TestFileCache_Write_Success(t *testing.T) {
dir, c := newTestFileCache(t)
size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999))
require.Nil(t, err)
require.Equal(t, int64(11), size)
require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl"))
require.Equal(t, int64(11), c.Size())
require.Equal(t, int64(10229), c.Remaining())
}
func TestFileCache_Write_Remove_Success(t *testing.T) {
dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)
for i := 0; i < 10; i++ { // 10x999 = 9990
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999)))
require.Nil(t, err)
require.Equal(t, int64(999), size)
}
require.Equal(t, int64(9990), c.Size())
require.Equal(t, int64(250), c.Remaining())
require.FileExists(t, dir+"/abcdefghijk1")
require.FileExists(t, dir+"/abcdefghijk5")
require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5"))
require.NoFileExists(t, dir+"/abcdefghijk1")
require.NoFileExists(t, dir+"/abcdefghijk5")
require.Equal(t, int64(7992), c.Size())
require.Equal(t, int64(2248), c.Remaining())
}
func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
dir, c := newTestFileCache(t)
for i := 0; i < 10; i++ {
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray))
require.Nil(t, err)
require.Equal(t, int64(1024), size)
}
_, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray))
require.Equal(t, util.ErrLimitReached, err)
require.NoFileExists(t, dir+"/abcdefghijkX")
}
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
dir, c := newTestFileCache(t)
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
require.Equal(t, util.ErrLimitReached, err)
require.NoFileExists(t, dir+"/abcdefghijkl")
}
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
dir = t.TempDir()
cache, err := newFileCache(dir, 10*1024)
require.Nil(t, err)
return dir, cache
}
func readFile(t *testing.T, f string) string {
b, err := os.ReadFile(f)
require.Nil(t, err)
return string(b)
}
+19 -20
View File
@@ -10,32 +10,31 @@ import (
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
// Log tags
const (
tagStartup = "startup"
tagHTTP = "http"
tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagTwilio = "twilio"
tagFileCache = "file_cache"
tagMessageCache = "message_cache"
tagStripe = "stripe"
tagAccount = "account"
tagManager = "manager"
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
tagWebPush = "webpush"
tagStartup = "startup"
tagHTTP = "http"
tagPublish = "publish"
tagSubscribe = "subscribe"
tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email
tagTwilio = "twilio"
tagStripe = "stripe"
tagAccount = "account"
tagManager = "manager"
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
tagWebPush = "webpush"
)
var (
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage}
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge}
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
)
@@ -55,12 +54,12 @@ func logvr(v *visitor, r *http.Request) *log.Event {
}
// logvrm creates a new log event with HTTP request, visitor fields and message fields
func logvrm(v *visitor, r *http.Request, m *message) *log.Event {
func logvrm(v *visitor, r *http.Request, m *model.Message) *log.Event {
return logvr(v, r).With(m)
}
// logvrm creates a new log event with visitor fields and message fields
func logvm(v *visitor, m *message) *log.Event {
func logvm(v *visitor, m *model.Message) *log.Event {
return logv(v).With(m)
}
File diff suppressed because it is too large Load Diff
-825
View File
@@ -1,825 +0,0 @@
package server
import (
"database/sql"
"fmt"
"github.com/stretchr/testify/assert"
"net/netip"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestSqliteCache_Messages(t *testing.T) {
testCacheMessages(t, newSqliteTestCache(t))
}
func TestMemCache_Messages(t *testing.T) {
testCacheMessages(t, newMemTestCache(t))
}
func testCacheMessages(t *testing.T, c *messageCache) {
m1 := newDefaultMessage("mytopic", "my message")
m1.Time = 1
m2 := newDefaultMessage("mytopic", "my other message")
m2.Time = 2
require.Nil(t, c.AddMessage(m1))
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
require.Nil(t, c.AddMessage(m2))
// Adding invalid
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
// mytopic: count
counts, err := c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 2, counts["mytopic"])
// mytopic: since all
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
require.Equal(t, 2, len(messages))
require.Equal(t, "my message", messages[0].Message)
require.Equal(t, "mytopic", messages[0].Topic)
require.Equal(t, messageEvent, messages[0].Event)
require.Equal(t, "", messages[0].Title)
require.Equal(t, 0, messages[0].Priority)
require.Nil(t, messages[0].Tags)
require.Equal(t, "my other message", messages[1].Message)
// mytopic: since none
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
require.Empty(t, messages)
// mytopic: since m1 (by ID)
messages, _ = c.Messages("mytopic", newSinceID(m1.ID), false)
require.Equal(t, 1, len(messages))
require.Equal(t, m2.ID, messages[0].ID)
require.Equal(t, "my other message", messages[0].Message)
require.Equal(t, "mytopic", messages[0].Topic)
// mytopic: since 2
messages, _ = c.Messages("mytopic", newSinceTime(2), false)
require.Equal(t, 1, len(messages))
require.Equal(t, "my other message", messages[0].Message)
// mytopic: latest
messages, _ = c.Messages("mytopic", sinceLatestMessage, false)
require.Equal(t, 1, len(messages))
require.Equal(t, "my other message", messages[0].Message)
// example: count
counts, err = c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 1, counts["example"])
// example: since all
messages, _ = c.Messages("example", sinceAllMessages, false)
require.Equal(t, "my example message", messages[0].Message)
// non-existing: count
counts, err = c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 0, counts["doesnotexist"])
// non-existing: since all
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
require.Empty(t, messages)
}
func TestSqliteCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newMemTestCache(t))
}
func testCacheMessagesLock(t *testing.T, c *messageCache) {
var wg sync.WaitGroup
for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
wg.Done()
}()
}
wg.Wait()
}
func TestSqliteCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newMemTestCache(t))
}
func testCacheMessagesScheduled(t *testing.T, c *messageCache) {
m1 := newDefaultMessage("mytopic", "message 1")
m2 := newDefaultMessage("mytopic", "message 2")
m2.Time = time.Now().Add(time.Hour).Unix()
m3 := newDefaultMessage("mytopic", "message 3")
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
m4 := newDefaultMessage("mytopic2", "message 4")
m4.Time = time.Now().Add(time.Minute).Unix()
require.Nil(t, c.AddMessage(m1))
require.Nil(t, c.AddMessage(m2))
require.Nil(t, c.AddMessage(m3))
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
require.Equal(t, 1, len(messages))
require.Equal(t, "message 1", messages[0].Message)
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
require.Equal(t, 3, len(messages))
require.Equal(t, "message 1", messages[0].Message)
require.Equal(t, "message 3", messages[1].Message) // Order!
require.Equal(t, "message 2", messages[2].Message)
messages, _ = c.MessagesDue()
require.Empty(t, messages)
}
func TestSqliteCache_Topics(t *testing.T) {
testCacheTopics(t, newSqliteTestCache(t))
}
func TestMemCache_Topics(t *testing.T) {
testCacheTopics(t, newMemTestCache(t))
}
func testCacheTopics(t *testing.T, c *messageCache) {
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
topics, err := c.Topics()
if err != nil {
t.Fatal(err)
}
require.Equal(t, 2, len(topics))
require.Equal(t, "topic1", topics["topic1"].ID)
require.Equal(t, "topic2", topics["topic2"].ID)
}
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
testCacheMessagesTagsPrioAndTitle(t, newMemTestCache(t))
}
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) {
m := newDefaultMessage("mytopic", "some message")
m.Tags = []string{"tag1", "tag2"}
m.Priority = 5
m.Title = "some title"
require.Nil(t, c.AddMessage(m))
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
require.Equal(t, 5, messages[0].Priority)
require.Equal(t, "some title", messages[0].Title)
}
func TestSqliteCache_MessagesSinceID(t *testing.T) {
testCacheMessagesSinceID(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesSinceID(t *testing.T) {
testCacheMessagesSinceID(t, newMemTestCache(t))
}
func testCacheMessagesSinceID(t *testing.T, c *messageCache) {
m1 := newDefaultMessage("mytopic", "message 1")
m1.Time = 100
m2 := newDefaultMessage("mytopic", "message 2")
m2.Time = 200
m3 := newDefaultMessage("mytopic", "message 3")
m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5
m4 := newDefaultMessage("mytopic", "message 4")
m4.Time = 400
m5 := newDefaultMessage("mytopic", "message 5")
m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7
m6 := newDefaultMessage("mytopic", "message 6")
m6.Time = 600
m7 := newDefaultMessage("mytopic", "message 7")
m7.Time = 700
require.Nil(t, c.AddMessage(m1))
require.Nil(t, c.AddMessage(m2))
require.Nil(t, c.AddMessage(m3))
require.Nil(t, c.AddMessage(m4))
require.Nil(t, c.AddMessage(m5))
require.Nil(t, c.AddMessage(m6))
require.Nil(t, c.AddMessage(m7))
// Case 1: Since ID exists, exclude scheduled
messages, _ := c.Messages("mytopic", newSinceID(m2.ID), false)
require.Equal(t, 3, len(messages))
require.Equal(t, "message 4", messages[0].Message)
require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5!
require.Equal(t, "message 7", messages[2].Message)
// Case 2: Since ID exists, include scheduled
messages, _ = c.Messages("mytopic", newSinceID(m2.ID), true)
require.Equal(t, 5, len(messages))
require.Equal(t, "message 4", messages[0].Message)
require.Equal(t, "message 6", messages[1].Message)
require.Equal(t, "message 7", messages[2].Message)
require.Equal(t, "message 5", messages[3].Message) // Order!
require.Equal(t, "message 3", messages[4].Message) // Order!
// Case 3: Since ID does not exist (-> Return all messages), include scheduled
messages, _ = c.Messages("mytopic", newSinceID("doesntexist"), true)
require.Equal(t, 7, len(messages))
require.Equal(t, "message 1", messages[0].Message)
require.Equal(t, "message 2", messages[1].Message)
require.Equal(t, "message 4", messages[2].Message)
require.Equal(t, "message 6", messages[3].Message)
require.Equal(t, "message 7", messages[4].Message)
require.Equal(t, "message 5", messages[5].Message) // Order!
require.Equal(t, "message 3", messages[6].Message) // Order!
// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), false)
require.Equal(t, 0, len(messages))
// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), true)
require.Equal(t, 2, len(messages))
require.Equal(t, "message 5", messages[0].Message)
require.Equal(t, "message 3", messages[1].Message)
}
func TestSqliteCache_Prune(t *testing.T) {
testCachePrune(t, newSqliteTestCache(t))
}
func TestMemCache_Prune(t *testing.T) {
testCachePrune(t, newMemTestCache(t))
}
func testCachePrune(t *testing.T, c *messageCache) {
now := time.Now().Unix()
m1 := newDefaultMessage("mytopic", "my message")
m1.Time = now - 10
m1.Expires = now - 5
m2 := newDefaultMessage("mytopic", "my other message")
m2.Time = now - 5
m2.Expires = now + 5 // In the future
m3 := newDefaultMessage("another_topic", "and another one")
m3.Time = now - 12
m3.Expires = now - 2
require.Nil(t, c.AddMessage(m1))
require.Nil(t, c.AddMessage(m2))
require.Nil(t, c.AddMessage(m3))
counts, err := c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 2, counts["mytopic"])
require.Equal(t, 1, counts["another_topic"])
expiredMessageIDs, err := c.MessagesExpired()
require.Nil(t, err)
require.Nil(t, c.DeleteMessages(expiredMessageIDs...))
counts, err = c.MessageCounts()
require.Nil(t, err)
require.Equal(t, 1, counts["mytopic"])
require.Equal(t, 0, counts["another_topic"])
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "my other message", messages[0].Message)
}
func TestSqliteCache_Attachments(t *testing.T) {
testCacheAttachments(t, newSqliteTestCache(t))
}
func TestMemCache_Attachments(t *testing.T) {
testCacheAttachments(t, newMemTestCache(t))
}
func testCacheAttachments(t *testing.T, c *messageCache) {
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
m.SequenceID = "m1"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "flower.jpg",
Type: "image/jpeg",
Size: 5000,
Expires: expires1,
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
}
require.Nil(t, c.AddMessage(m))
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
m = newDefaultMessage("mytopic", "sending you a car")
m.ID = "m2"
m.SequenceID = "m2"
m.Sender = netip.MustParseAddr("1.2.3.4")
m.Attachment = &attachment{
Name: "car.jpg",
Type: "image/jpeg",
Size: 10000,
Expires: expires2,
URL: "https://ntfy.sh/file/aCaRURL.jpg",
}
require.Nil(t, c.AddMessage(m))
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
m = newDefaultMessage("another-topic", "sending you another car")
m.ID = "m3"
m.SequenceID = "m3"
m.User = "u_BAsbaAa"
m.Sender = netip.MustParseAddr("5.6.7.8")
m.Attachment = &attachment{
Name: "another-car.jpg",
Type: "image/jpeg",
Size: 20000,
Expires: expires3,
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
}
require.Nil(t, c.AddMessage(m))
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
require.Equal(t, "flower for you", messages[0].Message)
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
require.Equal(t, int64(5000), messages[0].Attachment.Size)
require.Equal(t, expires1, messages[0].Attachment.Expires)
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[0].Sender.String())
require.Equal(t, "sending you a car", messages[1].Message)
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
require.Equal(t, int64(10000), messages[1].Attachment.Size)
require.Equal(t, expires2, messages[1].Attachment.Expires)
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(10000), size)
size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
require.Nil(t, err)
require.Equal(t, int64(0), size) // Accounted to the user, not the IP!
size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa")
require.Nil(t, err)
require.Equal(t, int64(20000), size)
}
func TestSqliteCache_Attachments_Expired(t *testing.T) {
testCacheAttachmentsExpired(t, newSqliteTestCache(t))
}
func TestMemCache_Attachments_Expired(t *testing.T) {
testCacheAttachmentsExpired(t, newMemTestCache(t))
}
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
m := newDefaultMessage("mytopic", "flower for you")
m.ID = "m1"
m.SequenceID = "m1"
m.Expires = time.Now().Add(time.Hour).Unix()
require.Nil(t, c.AddMessage(m))
m = newDefaultMessage("mytopic", "message with attachment")
m.ID = "m2"
m.SequenceID = "m2"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
Type: "image/jpeg",
Size: 10000,
Expires: time.Now().Add(2 * time.Hour).Unix(),
URL: "https://ntfy.sh/file/aCaRURL.jpg",
}
require.Nil(t, c.AddMessage(m))
m = newDefaultMessage("mytopic", "message with external attachment")
m.ID = "m3"
m.SequenceID = "m3"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "car.jpg",
Type: "image/jpeg",
Expires: 0, // Unknown!
URL: "https://somedomain.com/car.jpg",
}
require.Nil(t, c.AddMessage(m))
m = newDefaultMessage("mytopic2", "message with expired attachment")
m.ID = "m4"
m.SequenceID = "m4"
m.Expires = time.Now().Add(2 * time.Hour).Unix()
m.Attachment = &attachment{
Name: "expired-car.jpg",
Type: "image/jpeg",
Size: 20000,
Expires: time.Now().Add(-1 * time.Hour).Unix(),
URL: "https://ntfy.sh/file/aCaRURL.jpg",
}
require.Nil(t, c.AddMessage(m))
ids, err := c.AttachmentsExpired()
require.Nil(t, err)
require.Equal(t, 1, len(ids))
require.Equal(t, "m4", ids[0])
}
func TestSqliteCache_Migration_From0(t *testing.T) {
filename := newSqliteTestCacheFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 0" schema
_, err = db.Exec(`
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY,
time INT NOT NULL,
topic VARCHAR(64) NOT NULL,
message VARCHAR(1024) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT;
`)
require.Nil(t, err)
// Insert a bunch of messages
for i := 0; i < 10; i++ {
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create cache to trigger migration
c := newSqliteTestCacheFromFile(t, filename, "")
checkSchemaVersion(t, c.db)
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
require.Equal(t, "some message 5", messages[5].Message)
require.Equal(t, "", messages[5].Title)
require.Nil(t, messages[5].Tags)
require.Equal(t, 0, messages[5].Priority)
}
func TestSqliteCache_Migration_From1(t *testing.T) {
filename := newSqliteTestCacheFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 1" schema
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY,
time INT NOT NULL,
topic VARCHAR(64) NOT NULL,
message VARCHAR(512) NOT NULL,
title VARCHAR(256) NOT NULL,
priority INT NOT NULL,
tags VARCHAR(256) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
`)
require.Nil(t, err)
// Insert a bunch of messages
for i := 0; i < 10; i++ {
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create cache to trigger migration
c := newSqliteTestCacheFromFile(t, filename, "")
checkSchemaVersion(t, c.db)
// Add delayed message
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
require.Nil(t, c.AddMessage(delayedMessage))
// 10, not 11!
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
// 11!
messages, err = c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 11, len(messages))
// Check that index "idx_topic" exists
rows, err := c.db.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`)
require.Nil(t, err)
require.True(t, rows.Next())
var indexName string
require.Nil(t, rows.Scan(&indexName))
require.Equal(t, "idx_topic", indexName)
}
func TestSqliteCache_Migration_From9(t *testing.T) {
// This primarily tests the awkward migration that introduces the "expires" column.
// The migration logic has to update the column, using the existing "cache-duration" value.
filename := newSqliteTestCacheFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 8" schema
_, err = db.Exec(`
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
time INT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size INT NOT NULL,
attachment_expires INT NOT NULL,
attachment_url TEXT NOT NULL,
sender TEXT NOT NULL,
encoding TEXT NOT NULL,
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
COMMIT;
`)
require.Nil(t, err)
// Insert a bunch of messages
insertQuery := `
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
for i := 0; i < 10; i++ {
_, err = db.Exec(
insertQuery,
fmt.Sprintf("abcd%d", i),
time.Now().Unix(),
"mytopic",
fmt.Sprintf("some message %d", i),
"", // title
0, // priority
"", // tags
"", // click
"", // icon
"", // actions
"", // attachment_name
"", // attachment_type
0, // attachment_size
0, // attachment_type
"", // attachment_url
"9.9.9.9", // sender
"", // encoding
1, // published
)
require.Nil(t, err)
}
// Create cache to trigger migration
cacheDuration := 17 * time.Hour
c, err := newSqliteCache(filename, "", cacheDuration, 0, 0, false)
require.Nil(t, err)
checkSchemaVersion(t, c.db)
// Check version
rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`)
require.Nil(t, err)
require.True(t, rows.Next())
var version int
require.Nil(t, rows.Scan(&version))
require.Equal(t, currentSchemaVersion, version)
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
for _, m := range messages {
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
}
}
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
filename := newSqliteTestCacheFile(t)
startupQueries := `pragma journal_mode = WAL;
pragma synchronous = normal;
pragma temp_store = memory;`
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
require.Nil(t, err)
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
require.FileExists(t, filename)
require.FileExists(t, filename+"-wal")
require.FileExists(t, filename+"-shm")
}
func TestSqliteCache_StartupQueries_None(t *testing.T) {
filename := newSqliteTestCacheFile(t)
startupQueries := ""
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
require.Nil(t, err)
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
require.FileExists(t, filename)
require.NoFileExists(t, filename+"-wal")
require.NoFileExists(t, filename+"-shm")
}
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
filename := newSqliteTestCacheFile(t)
startupQueries := `xx error`
_, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
require.Error(t, err)
}
func TestSqliteCache_Sender(t *testing.T) {
testSender(t, newSqliteTestCache(t))
}
func TestMemCache_Sender(t *testing.T) {
testSender(t, newMemTestCache(t))
}
func testSender(t *testing.T, c *messageCache) {
m1 := newDefaultMessage("mytopic", "mymessage")
m1.Sender = netip.MustParseAddr("1.2.3.4")
require.Nil(t, c.AddMessage(m1))
m2 := newDefaultMessage("mytopic", "mymessage without sender")
require.Nil(t, c.AddMessage(m2))
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
require.Equal(t, messages[1].Sender, netip.Addr{})
}
func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) {
testDeleteScheduledBySequenceID(t, newSqliteTestCache(t))
}
func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) {
testDeleteScheduledBySequenceID(t, newMemTestCache(t))
}
func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) {
// Create a scheduled (unpublished) message
scheduledMsg := newDefaultMessage("mytopic", "scheduled message")
scheduledMsg.ID = "scheduled1"
scheduledMsg.SequenceID = "seq123"
scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
require.Nil(t, c.AddMessage(scheduledMsg))
// Create a published message with different sequence ID
publishedMsg := newDefaultMessage("mytopic", "published message")
publishedMsg.ID = "published1"
publishedMsg.SequenceID = "seq456"
publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
require.Nil(t, c.AddMessage(publishedMsg))
// Create a scheduled message in a different topic
otherTopicMsg := newDefaultMessage("othertopic", "other scheduled")
otherTopicMsg.ID = "other1"
otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
require.Nil(t, c.AddMessage(otherTopicMsg))
// Verify all messages exist (including scheduled)
messages, err := c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 2, len(messages))
messages, err = c.Messages("othertopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
// Delete scheduled message by sequence ID and verify returned IDs
deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123")
require.Nil(t, err)
require.Equal(t, 1, len(deletedIDs))
require.Equal(t, "scheduled1", deletedIDs[0])
// Verify scheduled message is deleted
messages, err = c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "published message", messages[0].Message)
// Verify other topic's message still exists (topic-scoped deletion)
messages, err = c.Messages("othertopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "other scheduled", messages[0].Message)
// Deleting non-existent sequence ID should return empty list
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent")
require.Nil(t, err)
require.Empty(t, deletedIDs)
// Deleting published message should not affect it (only deletes unpublished)
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456")
require.Nil(t, err)
require.Empty(t, deletedIDs)
messages, err = c.Messages("mytopic", sinceAllMessages, true)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "published message", messages[0].Message)
}
func checkSchemaVersion(t *testing.T, db *sql.DB) {
rows, err := db.Query(`SELECT version FROM schemaVersion`)
require.Nil(t, err)
require.True(t, rows.Next())
var schemaVersion int
require.Nil(t, rows.Scan(&schemaVersion))
require.Equal(t, currentSchemaVersion, schemaVersion)
require.Nil(t, rows.Close())
}
func TestMemCache_NopCache(t *testing.T) {
c, _ := newNopCache()
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
messages, err := c.Messages("mytopic", sinceAllMessages, false)
require.Nil(t, err)
require.Empty(t, messages)
topics, err := c.Topics()
require.Nil(t, err)
require.Empty(t, topics)
}
func newSqliteTestCache(t *testing.T) *messageCache {
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false)
if err != nil {
t.Fatal(err)
}
return c
}
func newSqliteTestCacheFile(t *testing.T) string {
return filepath.Join(t.TempDir(), "cache.db")
}
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
require.Nil(t, err)
return c
}
func newMemTestCache(t *testing.T) *messageCache {
c, err := newMemCache()
require.Nil(t, err)
return c
}
+49
View File
@@ -0,0 +1,49 @@
#!/sbin/openrc-run
# OpenRC service configuration for ntfy Server.
# Should be placed in /etc/init.d/ as "ntfy" or "ntfy-server" (no extension), owned by root:root and with permissions 755.
# Assumes an ntfy system user and group have been created, for example using this command:
# useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy
name=$RC_SVCNAME
description="ntfy server"
command="/usr/local/bin/ntfy"
command_background=true
command_args="serve"
command_user="ntfy:ntfy"
extra_started_commands="reload"
pidfile="/run/${RC_SVCNAME}/${RC_SVCNAME}.pid"
# Changes the hard number of open files (nofile) limit to 2048 for the service.
rc_ulimit="-n 2048"
# Allows the service to bind to privileged ports (<1024).
capabilities="^cap_net_bind_service"
error_log="/var/log/ntfy.log"
# Service dependencies
depend() {
use net
after firewall
}
# Check for - and if necessary - create required files and folders. Might require some adjustment dependings on the content of the server.yml file.
start_pre() {
checkpath -f --owner "$command_user" --mode 0644 \
/var/log/ntfy.log
checkpath -d --owner "$command_user" --mode 0750 \
/run/ntfy/
checkpath -d --owner "$command_user" --mode 0755 \
/var/lib/ntfy/
checkpath -d --owner "$command_user" --mode 0750 \
/var/cache/ntfy/
}
reload() {
ebegin "Reloading $RC_SVCNAME's configuration"
start-stop-daemon --signal SIGHUP --pidfile "${pidfile}"
eend $? "Failed to reload $RC_SVCNAME's configuration"
}
+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
+403 -552
View File
File diff suppressed because it is too large Load Diff
+82 -8
View File
@@ -38,14 +38,32 @@
#
# firebase-key-file: <filename>
# If "database-url" is set, ntfy will use PostgreSQL for database-backed stores instead of SQLite.
# Currently this applies to the web push subscription store. Message cache and user manager support
# will be added in future releases. When set, the "web-push-file" option is not required.
# If "database-url" is set, ntfy will use PostgreSQL for all database-backed stores (message cache,
# user manager, and web push subscriptions) instead of SQLite. When set, the "cache-file",
# "auth-file", and "web-push-file" options must not be set.
#
# database-url: "postgres://user:pass@host:5432/ntfy"
# Note: Setting "database-url" implicitly enables authentication and access control.
# The default access is "read-write" (see "auth-default-access").
#
# The URL supports standard PostgreSQL parameters (sslmode, connect_timeout, sslcert, etc.),
# as well as ntfy-specific connection pool parameters:
# pool_max_conns=10 - Maximum number of open connections (default: 10)
# pool_max_idle_conns=N - Maximum number of idle connections
# pool_conn_max_lifetime=5m - Maximum lifetime of a connection (Go duration)
# pool_conn_max_idle_time=1m - Maximum idle time of a connection (Go duration)
#
# See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
# for the full list of supported PostgreSQL connection parameters.
#
# Examples:
# database-url: "postgres://user:pass@host:5432/ntfy"
# database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50"
#
# database-url: <connection-string>
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
# This allows for service restarts without losing messages in support of the since= parameter.
# Not required if "database-url" is set (messages are stored in PostgreSQL instead).
#
# The "cache-duration" parameter defines the duration for which messages will be buffered
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
@@ -83,6 +101,8 @@
# If set, access to the ntfy server and API can be controlled on a granular level using
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
#
# Note: If "database-url" is set, auth is implicitly enabled and "auth-file" must not be set.
#
# - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist
# - auth-default-access defines the default/fallback access if no access control entry is found; it can be
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
@@ -96,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
@@ -111,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.
@@ -133,7 +156,8 @@
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
# are "attachment-cache-dir" and "base-url".
#
# - attachment-cache-dir is the cache directory for attached files
# - attachment-cache-dir is the cache directory for attached files, or an S3 URL for object storage
# e.g. /var/cache/ntfy/attachments, or s3://ACCESS_KEY:SECRET_KEY@bucket/prefix?region=us-east-1&endpoint=https://...
# - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size)
# - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M)
# - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h)
@@ -172,11 +196,14 @@
# - smtp-sender-addr is the hostname:port of the SMTP server
# - smtp-sender-from is the e-mail address of the sender
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
# - smtp-sender-verify is a flag that forces email recipient verification when enabled. If set to true,
# only verified email recipients can be used in the X-Email header.
#
# smtp-sender-addr:
# smtp-sender-from:
# smtp-sender-user:
# smtp-sender-pass:
# smtp-sender-verify: false
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
# emails to a topic e-mail address to publish messages to a topic.
@@ -203,6 +230,7 @@
# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db
# Not required if "database-url" is set (subscriptions are stored in PostgreSQL instead).
# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com
# - web-push-startup-queries is an optional list of queries to run on startup
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d)
@@ -333,6 +361,48 @@
# visitor-email-limit-burst: 16
# visitor-email-limit-replenish: "1h"
# Rate limiting: Allowed new topic creations per visitor. A "creation" is when a request
# causes a previously-unknown topic ID to be added to the in-memory topic map. Touches of
# existing topics do not consume tokens. Mitigates topic-enumeration / squatting attacks.
# - visitor-topic-creation-limit-burst is the initial bucket of new-topic tokens (0 = disabled)
# - visitor-topic-creation-limit-replenish is the rate at which the bucket is refilled
#
# visitor-topic-creation-limit-burst: 100
# visitor-topic-creation-limit-replenish: "1m"
# Abuse ban-feed: Count HTTP response statuses per visitor and append abusive IPs to a file that
# fail2ban (or similar) can tail and ban on sight. This captures ntfy-layer rejections (e.g. ACL
# 403s and ntfy's own 429s), so fail2ban does not have to regex-parse the full access log.
# - ban-file is the file abusive IPs are appended to; leave empty to disable the feature. Its
# directory must exist and be writable by ntfy. Rotate it (e.g. logrotate, copytruncate) so it
# cannot grow unbounded.
# - ban-window is the rolling window over which weighted strikes are counted, per visitor.
# - ban-threshold is the number of weighted strikes per window before a visitor is banned. Each
# visitor has ONE strike budget; rejections draw it down, so there is no way to game it by mixing
# codes.
# - ban-weights assigns a strike weight to a matcher KEY (KEY:WEIGHT). A KEY is an exact ntfy code
# ("42909"), a prefix family ("429*"), a bare HTTP status ("403", shorthand for "403*"), or "*".
# Longest match wins. A weight of 0 exempts a code (never contributes to a ban), so the legit quota
# 429s can be carved out from a "*" catch-all. Heavier weights ban faster (auth-failure floods).
#
# Each appended line has the exact format
# "<RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>", for example:
# 2026-07-17T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
# 2026-07-17T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
# <prefix> is <ip> masked to the rate-limiting prefix (visitor-prefix-bits-ipv4/ipv6); that is the
# unit a fail2ban jail should ban, so a whole IPv6 subnet is banned as one.
#
# ban-file: "/var/log/ntfy-ban.log"
# ban-window: "1m"
# ban-threshold: 100
# ban-weights:
# - "42909:10" # auth-failure flood: bans in ~10
# - "42908:0" # daily message quota reached -> legit, never counts
# - "42903:0" # subscription limit -> legit
# - "42905:0" # daily bandwidth reached -> legit
# - "42910:0" # daily phone call quota reached -> legit
# - "*:1" # everything else 4xx/5xx
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
@@ -347,7 +417,10 @@
# Rate limiting: Attachment size and bandwidth limits per visitor:
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
# - visitor-attachment-daily-bandwidth-limit is the total daily traffic limit per visitor. It covers
# attachment downloads/uploads AND messages replayed from the message cache by poll requests. A
# poll without a "since" cursor returns the topic's entire cache, so a busy topic can be re-read
# for many times its own size; charging it here caps what one visitor can pull per day.
#
# visitor-attachment-total-size-limit: "100M"
# visitor-attachment-daily-bandwidth-limit: "500M"
@@ -386,8 +459,9 @@
# doing, and/or secure access to the endpoint in your reverse proxy.
#
# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly
# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
# - metrics-listen-http moves the metrics endpoint to a dedicated [IP]:port, e.g. "10.0.1.1:9090" or ":9090".
# It implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default
# ntfy server does not serve /metrics, even if enable-metrics is also set.
#
# enable-metrics: false
# metrics-listen-http:
+385 -42
View File
@@ -3,18 +3,23 @@ package server
import (
"encoding/json"
"errors"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"net/http"
"net/netip"
"strings"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/twilio"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
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 {
@@ -25,14 +30,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
}
@@ -43,7 +51,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())
}
@@ -90,6 +108,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
if u.Prefs.Language != nil {
response.Language = *u.Prefs.Language
}
if u.Prefs.DateFormat != nil {
response.DateFormat = *u.Prefs.DateFormat
}
if u.Prefs.TimeFormat != nil {
response.TimeFormat = *u.Prefs.TimeFormat
}
if u.Prefs.Notification != nil {
response.Notification = u.Prefs.Notification
}
@@ -158,6 +182,27 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.PhoneNumbers = phoneNumbers
}
}
if s.mailer != nil {
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
}
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 {
response.Username = user.Everyone
response.Role = string(user.RoleAnonymous)
@@ -224,6 +269,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse())
}
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
// user in, so it also reports who they are (the identifier they typed may be a primary email).
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
if err != nil {
return err
}
response := &apiAccountLoginResponse{
Token: token.Value,
Username: u.Name,
}
return s.writeJSON(w, response)
}
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
if err != nil {
@@ -335,6 +398,12 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
if newPrefs.Language != nil {
prefs.Language = newPrefs.Language
}
if newPrefs.DateFormat != nil {
prefs.DateFormat = newPrefs.DateFormat
}
if newPrefs.TimeFormat != nil {
prefs.TimeFormat = newPrefs.TimeFormat
}
if newPrefs.Notification != nil {
if prefs.Notification == nil {
prefs.Notification = &user.NotificationPrefs{}
@@ -454,21 +523,8 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
return errHTTPUnauthorized
} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
return errHTTPConflictTopicReserved
} else if u.IsUser() {
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
if err != nil {
return err
}
if !hasReservation {
reservations, err := s.userManager.ReservationsCount(u.Name)
if err != nil {
return err
} else if reservations >= u.Tier.ReservationLimit {
return errHTTPTooManyRequestsLimitReservations
}
}
}
// Actually add the reservation
// Actually add the reservation (with limit check inside the transaction to avoid races)
logvr(v, r).
Tag(tagAccount).
Fields(log.Context{
@@ -476,11 +532,18 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
"everyone": everyone.String(),
}).
Debug("Adding topic reservation")
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil {
var limit int64
if u.IsUser() && u.Tier != nil {
limit = u.Tier.ReservationLimit
}
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone, limit); err != nil {
if errors.Is(err, user.ErrTooManyReservations) {
return errHTTPTooManyRequestsLimitReservations
}
return err
}
// Kill existing subscribers
t, err := s.topicFromID(req.Topic)
t, err := s.topicFromID(v, req.Topic)
if err != nil {
return err
}
@@ -529,22 +592,15 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
// The process relies on the manager to perform the actual deletions (see runManager).
func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {
reservations, err := s.userManager.Reservations(u.Name)
removedTopics, err := s.userManager.RemoveExcessReservations(u.Name, reservationsLimit)
if err != nil {
return err
} else if int64(len(reservations)) <= reservationsLimit {
} else if len(removedTopics) == 0 {
logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove")
return nil
}
topics := make([]string, 0)
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
topics = append(topics, reservations[i].Topic)
}
logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", "))
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
return err
}
if err := s.messageCache.ExpireMessages(topics...); err != nil {
logvr(v, r).Tag(tagAccount).Info("Removed excess topic reservations, now removing messages for topics %s", strings.Join(removedTopics, ", "))
if err := s.messageCache.ExpireMessages(removedTopics...); err != nil {
return err
}
go s.pruneMessages()
@@ -576,7 +632,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
}
// Actually add the unverified number, and send verification
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
if err := s.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil {
if err := s.twilio.Verify(req.Number, req.Channel); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
@@ -591,7 +647,10 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid
}
if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil {
if err := s.twilio.CheckVerify(req.Number, req.Code); err != nil {
if errors.Is(err, twilio.ErrVerificationExpired) {
return errHTTPGonePhoneVerificationExpired
}
return err
}
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
@@ -617,6 +676,269 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
return s.writeJSON(w, newSuccessResponse())
}
// 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[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 (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
}
// 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 emails.Contains(req.Email) {
return errHTTPConflictEmailExists
}
// Rate limit (counts against the user's email quota)
if !v.EmailAllowed() {
return errHTTPTooManyRequestsLimitEmails
}
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())
}
// 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[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).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())
}
// 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
} else if s.userManager == nil {
return email, nil
}
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return "", errHTTPInternalError
} else if emails.Contains(email) {
return email, nil
}
return "", errHTTPBadRequestEmailAddressNotVerified
}
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) {
go func() {
@@ -626,14 +948,35 @@ 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
}
logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic")
syncTopic, err := s.topicFromID(u.SyncTopic)
syncTopic, err := s.topicFromID(nil, u.SyncTopic) // internal: no rate limit
if err != nil {
return err
}
@@ -641,8 +984,8 @@ func (s *Server) publishSyncEvent(v *visitor) error {
if err != nil {
return err
}
m := newDefaultMessage(syncTopic.ID, string(messageBytes))
if err := syncTopic.Publish(v, m); err != nil {
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
if err := s.dispatch(v, syncTopic, m, dispatchOpts{}); err != nil {
return err
}
return nil
+468
View File
@@ -0,0 +1,468 @@
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_LoginByPrimaryEmail(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")
// Basic Auth works with either the username or the verified primary email
require.True(t, canLogin(t, s, "ben", "ben"))
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
// ...but not with the wrong password or an unknown email
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
})
}
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)
})
}
File diff suppressed because it is too large Load Diff

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