Compare commits

..

200 Commits

Author SHA1 Message Date
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
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
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
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
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
binwiederhier
ae5e1fe8d8 Merge branch 'postgres-webpush' into postgres-webpush+user 2026-02-17 20:15:27 -05:00
binwiederhier
e3a402ed95 Make user tests work for postgres and sqlite 2026-02-17 20:14:45 -05:00
binwiederhier
1abc1005d0 Re-org 2026-02-17 12:04:51 -05:00
binwiederhier
909c3fe17b Add Store-level unit tests for SQLite and PostgreSQL backends
Add shared test functions in store_test.go covering all Store interface
operations (users, tokens, access control, tiers, billing, stats, etc.)
with per-backend wrappers in store_sqlite_test.go and store_postgres_test.go
following the webpush test pattern.

Fix broken isUniqueConstraintError() which used incorrect interface
assertions instead of string matching for SQLite/PostgreSQL errors.
2026-02-16 22:56:31 -05:00
binwiederhier
07c3e280bf Refactor user package to Store interface with PostgreSQL support
Extract database operations from Manager into a Store interface with
SQLite and PostgreSQL implementations using a shared commonStore.
Split SQLite migrations into store_sqlite_migrations.go, use shared
schema_version table for PostgreSQL, rename user_user/user_tier tables
to "user"/tier, and wire up database-url in CLI commands.
2026-02-16 22:39:54 -05:00
binwiederhier
b567b4e904 Merge branch 'main' into postgres-webpush 2026-02-16 21:05:04 -05:00
binwiederhier
60fa50f0d5 Merge branch 'main' into postgres-webpush+user 2026-02-16 21:04:46 -05:00
binwiederhier
ceda5ec3d8 Move things in user package 2026-02-16 21:04:15 -05:00
binwiederhier
3d72845c81 Tests for user package 2026-02-16 20:49:17 -05:00
binwiederhier
82e15d84bd Manual changes 2026-02-16 20:02:19 -05:00
binwiederhier
4e5f95ba0c Refactor webpush store to eliminate code duplication
Consolidate SQLite and Postgres store implementations into a single
commonStore with database-specific SQL queries passed via configuration.
This eliminates ~100 lines of duplicate code while maintaining full
functionality for both backends.

Key changes:
- Move all store methods to commonStore in store.go
- Remove sqliteStore and postgresStore wrapper structs
- Refactor SQLite to use QueryRow() pattern like Postgres
- Pass database-specific queries via storeQueries struct
- Make store types unexported, only expose Store interface

All tests pass for both SQLite and PostgreSQL backends.
2026-02-16 19:53:34 -05:00
binwiederhier
869b972a50 Manual review 2026-02-16 19:12:14 -05:00
binwiederhier
bdd20197b3 Manual refinements 2026-02-16 19:06:45 -05:00
binwiederhier
a8dcecdb6d Refactor webpush store tests and add coverage
- Add SetSubscriptionUpdatedAt to Store interface, remove DB() accessor
- Rename store files to store_sqlite.go and store_postgres.go
- Use camelCase for test function names
- Add tests for upsert field updates and multi-user removal
- Use transaction in setupNewPostgresDB
- Use lowercase "excluded." in PostgreSQL upsert query
2026-02-16 18:53:12 -05:00
binwiederhier
5331437664 Unify webpush store tests across SQLite and PostgreSQL backends
Share test logic in store_test.go with thin per-backend wrappers.
Add SetSubscriptionUpdatedAt to both stores, removing the need for
raw SQL and the DB() accessor in tests.
2026-02-16 12:38:00 -05:00
binwiederhier
e432bf2886 Rename PostgreSQL table prefix from wp_ to webpush_ 2026-02-16 12:13:10 -05:00
binwiederhier
0edad84d86 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-02-16 10:55:17 -05:00
Philipp C. Heckel
ddf728acd1 Merge pull request #1607 from ZebMcKayhan/main
Added SIA-Server to integration list
2026-02-16 10:49:34 -05:00
Philipp C. Heckel
b1d3671dbb Merge pull request #1610 from luneth/patch-1
docs: Clarify F-Droid flavor excludes Google Services
2026-02-16 09:46:51 -05:00
luneth
3e6b46ec0c Fix: Clarify F-Droid flavor excludes Google Services
Updated documentation to reflect that the F-Droid flavor automatically excludes Google Services dependencies [here](33a36c4b54/app/build.gradle (L82))
2026-02-16 15:43:57 +01:00
ZebMcKayhan
b16d381626 Update integrations.md
Added SIA-Server to project list.
2026-02-15 19:41:22 +01:00
Mazurky
3bd1a1ea03 Translated using Weblate (Slovak)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sk/
2026-02-13 22:09:46 +00:00
Philipp C. Heckel
7adb37b94b Merge pull request #1603 from cyqsimon/npm-override
Allow overriding `npm` binary in Makefile
2026-02-13 16:22:50 -05:00
cyqsimon
bc08819525 Allow overriding npm binary in Makefile 2026-02-12 17:57:09 +08:00
binwiederhier
a03a37feb1 Merge branch 'main' into release-2.17.x 2026-02-08 22:31:35 -05:00
binwiederhier
4cd556f5aa Copy fix 2026-02-08 22:31:22 -05:00
binwiederhier
90aeb811ff Changelog 2026-02-08 22:18:45 -05:00
binwiederhier
c6ab380ea4 Merge branch '1364-copy-action' 2026-02-08 20:46:54 -05:00
binwiederhier
7860f2142c Constants 2026-02-08 15:11:06 -05:00
binwiederhier
18d5d31bd2 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-02-08 14:29:04 -05:00
binwiederhier
cfdc364e3f Version API endpoint 2026-02-08 14:28:27 -05:00
Philipp C. Heckel
763215ecfa Merge pull request #1595 from epifeny/fix/docker-build-include-payments
fix: add payments dir to docker build
2026-02-08 11:24:54 -08:00
binwiederhier
3f0a7b65ee Server/Web: Support "copy" action button to copy a value to the clipboard 2026-02-08 14:20:03 -05:00
binwiederhier
65050ef4dc Fix server crash (nil pointer panic) when subscriber disconnects during publish 2026-02-08 11:23:31 -05:00
binwiederhier
3647d3975c Fix panic in handleSubscribeHTTP when client disconnects during publish
Replace wlock.TryLock() with a proper Lock() + closed flag to prevent
writing to a response writer that has been cleaned up after the handler
returns. The previous TryLock approach could not guarantee the response
writer was still valid when a concurrent Publish goroutine called Flush.
2026-02-08 10:49:31 -05:00
binwiederhier
06ea1f98ac Merge branch 'main' of github.com:binwiederhier/ntfy 2026-02-08 10:29:13 -05:00
binwiederhier
2827df26ee Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-02-08 10:29:03 -05:00
binwiederhier
fe6ee1efa0 Web: Show red notification dot on favicon when there are unread messages 2026-02-08 10:28:46 -05:00
Philipp C. Heckel
14df6462df Merge pull request #1597 from i-abc/fix-gpg-fingerprint-typo
Fix GPG key fingerprint typo in docs/install.md
2026-02-07 06:51:44 -08:00
i-abc
b9f659c8ac docs: fix GPG key fingerprint typo 2026-02-07 20:49:31 +08:00
binwiederhier
623fd4f224 Changelog 2026-02-06 06:53:58 -08:00
epifeny
49991d5aa7 fix: add payments dir to docker build 2026-02-06 10:12:34 +02:00
binwiederhier
1b554d5b08 Web: Fix clear=true on action buttons not clearing the notification 2026-02-05 09:40:18 -08:00
Yaron Shahrabani
7b0eb3d467 Translated using Weblate (Hebrew)
Currently translated at 12.2% (50 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/he/
2026-02-05 15:01:50 +00:00
binwiederhier
6978fa69a8 Add better F-Droid logo 2026-02-04 18:32:08 -08:00
binwiederhier
a1da18b99f Show last notification time for UnifiedPush subscriptions 2026-02-04 17:52:24 -08:00
binwiederhier
570b188a88 Support for templating the priority header 2026-02-04 09:46:09 -08:00
binwiederhier
b34d23870b Release notes 2026-02-04 08:35:38 -08:00
binwiederhier
08eaafa77b Fix log spam from http: response.WriteHeader on hijacked connection for WebSocket errors 2026-02-04 06:05:54 -08:00
Yaron Shahrabani
325983deaf Translated using Weblate (Hebrew)
Currently translated at 8.1% (33 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/he/
2026-02-04 07:01:50 +00:00
binwiederhier
fe386e31dd Changelog for #1112 (Android notification timestamp fix) 2026-02-03 18:36:53 -05:00
Yaron Shahrabani
bfb47c4046 Added translation using Weblate (Hebrew) 2026-02-03 07:55:04 +01:00
Kachelkaiser
ad334178de Translated using Weblate (German)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2026-02-03 07:55:04 +01:00
Philipp C. Heckel
68e22ebe7d Merge pull request #1587 from zigazajc007/main
Update change URL of Uptime Monitor to official website
2026-02-02 10:53:00 -05:00
Ziga Zajc
23aff6fb06 Update change URL of Uptime Monitor to official website 2026-02-02 05:48:02 +01:00
binwiederhier
e01e9d6491 Changelog 2026-02-01 22:37:51 -05:00
binwiederhier
f382a13109 Lint 2026-02-01 22:17:35 -05:00
binwiederhier
b2f4046574 Release notes 2026-02-01 22:12:33 -05:00
binwiederhier
fd836cacf6 Release notes, and send to ntfy extension 2026-02-01 20:52:06 -05:00
binwiederhier
7207839b2a Fix long lines in web app by adding horizontal scroll, closes #1363 2026-02-01 17:42:56 -05:00
binwiederhier
9fbf5e460e Release notes 2026-02-01 17:35:09 -05:00
Philipp C. Heckel
08bf71b248 Merge pull request #1406 from tanhuaan/main
refactor: use slices.Contains to simplify code
2026-02-01 17:31:40 -05:00
Philipp C. Heckel
b3d246d1f8 Merge pull request #1581 from zigazajc007/main
Add Uptime Monitor to the Official integrations list
2026-02-01 17:01:17 -05:00
binwiederhier
946a2b6fbe Use full URL in curl example on empty topic pages, closes #1535, closes #1435 2026-02-01 16:56:02 -05:00
Ziga Zajc
b57bc5d86e Update move project from Official integrations to Projects + Scripts. 2026-02-01 20:34:58 +01:00
Ziga Zajc
62f3d991b4 Merge branch 'binwiederhier:main' into main 2026-02-01 20:25:45 +01:00
binwiederhier
1cf23a6f86 Fix markdown message line height, closes #1139 2026-02-01 12:41:00 -05:00
binwiederhier
63e9b8425f Add validation to "add user" dialog, closes #1566 2026-02-01 12:33:19 -05:00
binwiederhier
4546eb02a1 Docs fix: Update Kustomize config file, closes #1367 2026-02-01 12:22:48 -05:00
binwiederhier
c33f1494f5 Release notes 2026-02-01 12:20:19 -05:00
binwiederhier
27a4a71b23 Update docs 2026-02-01 12:01:57 -05:00
binwiederhier
11a8d2e3b4 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-02-01 08:18:25 -05:00
Ziga Zajc
f15a74521a Update add Uptime Monitor to the Official integrations list 2026-01-30 22:40:19 +01:00
109247019824
df22835932 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2026-01-28 11:48:54 +01:00
Philipp C. Heckel
eb3549eedc Merge pull request #1577 from KavyanshKhaitan2/patch-2
[Docs/publish] Fix JSON syntax for JS examples
2026-01-27 09:35:15 -05:00
Kavyansh Khaitan
cea5fececb Fix JSON syntax for JS examples
(and make one of them more consistent)
2026-01-27 20:00:29 +05:30
Philipp C. Heckel
f686b8c548 Merge pull request #1572 from oshliaer/chore_fix_some_typos_in_server-yml
chore: fix typos in server.yml config file
2026-01-26 21:19:22 -05:00
binwiederhier
448c5bfb88 Terms of service 2026-01-26 20:31:11 -05:00
Philipp C. Heckel
5c3fad28be Merge pull request #1574 from KavyanshKhaitan2/patch-1
[docs] Fix typo/syntax error in publish.md
2026-01-26 07:06:26 -05:00
Kavyansh Khaitan
f79c84e99e Fix syntax error in publish.md 2026-01-26 17:31:15 +05:30
Alexander Ivanov
1a172eb73b 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-01-25 20:19:11 +01:00
Alexander Ivanov
b26546b709 chore: fix typos in server.yml config file
Signed-off-by: Alexander Ivanov <oshli.a.er@gmail.com>
2026-01-25 19:19:31 +03:00
binwiederhier
2ae962d957 Bump 2026-01-25 09:24:30 -05:00
binwiederhier
2343ce46bd Release notes 2026-01-25 09:16:36 -05:00
binwiederhier
a12e18cf12 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-01-25 09:16:20 -05:00
jonnysemon
64309c0101 Translated using Weblate (Arabic)
Currently translated at 88.9% (362 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ar/
2026-01-24 19:04:39 +01:00
Shjosan
b9844f48f1 Translated using Weblate (Swedish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sv/
2026-01-22 15:18:46 +01:00
binwiederhier
77872f1b6a Remove feature not released notice 2026-01-20 15:54:46 -05:00
binwiederhier
7a7c94cd40 Docs docs docs 2026-01-20 13:52:03 -05:00
binwiederhier
01ef1d3004 Add FreeBSD to the install instructions 2026-01-19 21:05:25 -05:00
binwiederhier
d8232e539a PWA description 2026-01-19 20:38:42 -05:00
binwiederhier
860954bdc8 DerDerpp 2026-01-19 20:33:19 -05:00
binwiederhier
f4fe62bd91 Logo update 2026-01-19 20:29:28 -05:00
binwiederhier
4b474a89b7 Docs 2026-01-19 18:29:45 -05:00
binwiederhier
5ba1c71140 Fix grouping issue with sequence ID 2026-01-18 21:30:12 -05:00
binwiederhier
de81865c27 Bump deps 2026-01-18 20:11:48 -05:00
binwiederhier
ed9c1bcb78 Wording change 2026-01-18 19:46:14 -05:00
binwiederhier
190d12cd54 Release banner 2026-01-18 19:41:34 -05:00
Philipp C. Heckel
63bf82e915 Merge pull request #1556 from binwiederhier/cancel-scheduled
Updated/cancel scheduled messages
2026-01-18 19:38:52 -05:00
binwiederhier
014b7355c5 Re-org 2026-01-18 19:24:01 -05:00
binwiederhier
602f201bae Derp 2026-01-18 19:15:10 -05:00
binwiederhier
2739d8a325 Re-organize docs 2026-01-18 19:09:14 -05:00
binwiederhier
b8e01fde33 Docs 2026-01-18 16:22:06 -05:00
binwiederhier
9ecf21c65a Docs 2026-01-18 16:16:04 -05:00
binwiederhier
ac9cfbfaf4 Delete attachments 2026-01-18 16:04:42 -05:00
binwiederhier
c23d201186 Updated/cancel scheduled messages 2026-01-18 15:50:40 -05:00
binwiederhier
86157fc7f6 Lint 2026-01-18 11:14:07 -05:00
binwiederhier
279c164bf5 Fix build 2026-01-18 11:13:56 -05:00
binwiederhier
743b00e59c Merge branch 'main' of github.com:binwiederhier/ntfy 2026-01-18 10:57:10 -05:00
binwiederhier
eddf654b96 Last fixes 2026-01-18 10:56:11 -05:00
binwiederhier
886be722bc Release notes 2026-01-18 10:52:27 -05:00
binwiederhier
6886ca24b1 Self-review 2026-01-18 10:51:36 -05:00
binwiederhier
856f150958 Better 2026-01-18 10:46:15 -05:00
binwiederhier
5a1aa68ead Refine 2026-01-18 09:44:21 -05:00
binwiederhier
cc9f9c0d24 Update checker 2026-01-17 20:36:15 -05:00
Philipp C. Heckel
8deb2df88d Update Windows support details in releases.md 2026-01-17 20:26:37 -05:00
Philipp C. Heckel
603273ab9d Merge pull request #1552 from binwiederhier/windows-server
Support "ntfy serve" on Windows
2026-01-17 18:12:37 -05:00
binwiederhier
64b0bd63af Deps 2026-01-17 18:05:36 -05:00
binwiederhier
220372d65a Move client config file logic, docs 2026-01-17 17:51:33 -05:00
binwiederhier
353fedb93f Docs, lint 2026-01-17 14:59:43 -05:00
binwiederhier
dfd12528f3 Manual nits 2026-01-17 14:48:32 -05:00
binwiederhier
6d5cc6aeac Windows server support 2026-01-17 14:43:43 -05:00
binwiederhier
9f3883eaf0 Build server on Windows 2026-01-17 14:00:08 -05:00
Philipp C. Heckel
a0ebd64461 Merge pull request #1551 from mshafer1/dev/update_link_to_ntfysh-windows
Update link to ntfysh-windows client
2026-01-17 12:20:09 -05:00
Maker By Night
dafd130fe5 Update link to ntfysh-windows client
Since lucas-bortoli marked the original project as archived/abandoned, I intend to continue maintenance on my fork. Therefore, updating the integration link to point to it.
2026-01-17 10:03:29 -06:00
binwiederhier
11e9e1e6a0 Merge branch 'feature/twilio-call-format-file' 2026-01-17 05:00:03 -05:00
binwiederhier
b23f6632b1 Use Go templates, update docs 2026-01-17 04:59:46 -05:00
binwiederhier
6bacf7dafc Works 2026-01-17 04:34:32 -05:00
binwiederhier
0e200b96e0 Merge branch 'main' of github.com:binwiederhier/ntfy into feature/twilio-call-format-file 2026-01-17 03:49:52 -05:00
binwiederhier
3ce56879ae Tidy 2026-01-17 03:49:33 -05:00
binwiederhier
48efdffa57 Fix indent 2026-01-17 03:29:40 -05:00
Philipp C. Heckel
9135bb277b Merge pull request #1534 from Pixelguin/feat/ws-permissions-note
Add troubleshooting steps for "Reconnecting" error on mobile
2026-01-17 03:21:07 -05:00
binwiederhier
711899ad35 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-01-17 03:20:47 -05:00
binwiederhier
01435d5fea Bump 2026-01-17 03:20:41 -05:00
Philipp C. Heckel
8ce2188b28 Merge pull request #1536 from binwiederhier/303-update-notifications
Update/delete/clear notifications
2026-01-16 10:10:50 -05:00
waclaw66
a712d78e4c Translated using Weblate (Czech)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/
2026-01-15 14:01:47 +01:00
cyberboh
c0a5a1fb35 Translated using Weblate (Indonesian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2026-01-13 10:30:17 +01:00
Pixelguin
1c32ee7613 Clarify wording 2026-01-05 08:36:56 -08:00
Pixelguin
f356309f70 Clarify up* r/w permissions 2026-01-05 08:13:53 -08:00
Pixelguin
39936a95f8 Add troubleshooting steps for "Reconnecting" error on mobile 2026-01-05 08:11:20 -08:00
tanhuaan
0e79c4bd2a refactor: use slices.Contains to simplify code
Signed-off-by: tanhuaan <tanhuaan@outlook.com>
2025-07-30 16:32:57 +08:00
Michael Nowak
16900d2c10 Set twilio-call-format config option in serve command 2025-06-16 15:14:13 +02:00
Michael Nowak
950ba1e2e1 Add optional twilio-call-format config option
To be able to set custom TwiML send to the Call API.
2025-03-11 09:45:32 +00:00
145 changed files with 20036 additions and 12851 deletions

View File

@@ -6,6 +6,22 @@ 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"
steps:
- name: Checkout code
uses: actions/checkout@v3

View File

@@ -3,6 +3,22 @@ on: [ push, 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"
steps:
- name: Checkout code
uses: actions/checkout@v3
@@ -23,7 +39,7 @@ 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

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ build/
server/docs/
server/site/
tools/fbsend/fbsend
tools/pgimport/pgimport
playground/
secrets/
*.iml

View File

@@ -48,13 +48,15 @@ builds:
- id: ntfy_windows_amd64
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
tags: [ noserver ] # don't include server files
- CGO_ENABLED=1 # required for go-sqlite3
- CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
ldflags:
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
- "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [ windows ]
goarch: [ amd64 ]
- id: ntfy_darwin_all
goarch: [amd64 ]
-
id: ntfy_darwin_all
binary: ntfy
env:
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
@@ -201,4 +203,4 @@ docker_manifests:
- *amd64_image
- *arm64v8_image
- *armv7_image
- *armv6_image
- *armv6_image

View File

@@ -40,6 +40,7 @@ ADD ./log ./log
ADD ./server ./server
ADD ./user ./user
ADD ./util ./util
ADD ./payments ./payments
RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
FROM alpine

View File

@@ -1,4 +1,5 @@
MAKEFLAGS := --jobs=1
NPM := npm
PYTHON := python3
PIP := pip3
VERSION := $(shell git describe --tag)
@@ -31,6 +32,7 @@ help:
@echo "Build server & client (without GoReleaser):"
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
@echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)"
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
@echo
@echo "Build dev Docker:"
@@ -106,6 +108,7 @@ build-deps-ubuntu:
curl \
gcc-aarch64-linux-gnu \
gcc-arm-linux-gnueabi \
gcc-mingw-w64-x86-64 \
python3 \
python3-venv \
jq
@@ -135,7 +138,7 @@ web: web-deps web-build
web-build:
cd web \
&& npm run build \
&& $(NPM) run build \
&& mv build/index.html build/app.html \
&& rm -rf ../server/site \
&& mv build ../server/site \
@@ -143,20 +146,20 @@ web-build:
../server/site/config.js
web-deps:
cd web && npm install
cd web && $(NPM) install
# If this fails for .svg files, optimize them with svgo
web-deps-update:
cd web && npm update
cd web && $(NPM) update
web-fmt:
cd web && npm run format
cd web && $(NPM) run format
web-fmt-check:
cd web && npm run format:check
cd web && $(NPM) run format:check
web-lint:
cd web && npm run lint
cd web && $(NPM) run lint
# Main server/client build
@@ -201,6 +204,16 @@ cli-darwin-server: cli-deps-static-sites
-ldflags \
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
cli-windows-server: cli-deps-static-sites
# This is a target to build the CLI (including the server) for Windows.
# Use this for Windows development, if you really don't want to install GoReleaser ...
mkdir -p dist/ntfy_windows_server server/docs
CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \
-o dist/ntfy_windows_server/ntfy.exe \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
cli-client: cli-deps-static-sites
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
# Use this for development, if you really don't want to install GoReleaser ...
@@ -213,7 +226,7 @@ cli-client: cli-deps-static-sites
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows
cli-deps-static-sites:
mkdir -p server/docs server/site
@@ -228,8 +241,12 @@ cli-deps-gcc-armv6-armv7:
cli-deps-gcc-arm64:
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
cli-deps-gcc-windows:
which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; }
cli-deps-update:
go get -u
go mod tidy
go install honnef.co/go/tools/cmd/staticcheck@latest
go install golang.org/x/lint/golint@latest
go install github.com/goreleaser/goreleaser/v2@latest
@@ -248,11 +265,13 @@ cli-build-results:
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
test: .PHONY
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
go test -parallel 3 $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
testv: .PHONY
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
go test -v -parallel 3 $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
race: .PHONY
go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')

View File

@@ -34,6 +34,12 @@ You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There
available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),
as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
<p>
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img height="50" src="docs/static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="docs/static/img/badge-fdroid.svg"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img height="50" src="docs/static/img/badge-appstore.png"></a>
</p>
<p>
<img src=".github/images/screenshot-curl.png" height="180">
<img src=".github/images/screenshot-web-detail.png" height="180">

View File

@@ -11,6 +11,9 @@ const (
DefaultBaseURL = "https://ntfy.sh"
)
// DefaultConfigFile is the default path to the client config file (set in config_*.go)
var DefaultConfigFile string
// Config is the config struct for a Client
type Config struct {
DefaultHost string `yaml:"default-host"`

18
client/config_darwin.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build darwin
package client
import (
"os"
"os/user"
"path/filepath"
)
func init() {
u, err := user.Current()
if err == nil && u.Uid == "0" {
DefaultConfigFile = "/etc/ntfy/client.yml"
} else if configDir, err := os.UserConfigDir(); err == nil {
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
}
}

18
client/config_unix.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build linux || dragonfly || freebsd || netbsd || openbsd
package client
import (
"os"
"os/user"
"path/filepath"
)
func init() {
u, err := user.Current()
if err == nil && u.Uid == "0" {
DefaultConfigFile = "/etc/ntfy/client.yml"
} else if configDir, err := os.UserConfigDir(); err == nil {
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
}
}

14
client/config_windows.go Normal file
View File

@@ -0,0 +1,14 @@
//go:build windows
package client
import (
"os"
"path/filepath"
)
func init() {
if configDir, err := os.UserConfigDir(); err == nil {
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
}
}

View File

@@ -3,11 +3,12 @@ package cmd
import (
"fmt"
"os"
"regexp"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"os"
"regexp"
)
const (
@@ -15,6 +16,12 @@ const (
categoryServer = "Server commands"
)
// Build metadata keys for app.Metadata
const (
MetadataKeyCommit = "commit"
MetadataKeyDate = "date"
)
var commands = make([]*cli.Command, 0)
var flagsDefault = []cli.Flag{

View File

@@ -10,10 +10,9 @@ import (
"net"
"net/netip"
"net/url"
"os"
"os/signal"
"runtime"
"strings"
"syscall"
"text/template"
"time"
"github.com/urfave/cli/v2"
@@ -40,6 +39,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "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.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)"}),
@@ -77,6 +77,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
@@ -143,6 +144,7 @@ func execServe(c *cli.Context) error {
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
databaseURL := c.String("database-url")
webPushPrivateKey := c.String("web-push-private-key")
webPushPublicKey := c.String("web-push-public-key")
webPushFile := c.String("web-push-file")
@@ -187,6 +189,7 @@ func execServe(c *cli.Context) error {
twilioAuthToken := c.String("twilio-auth-token")
twilioPhoneNumber := c.String("twilio-phone-number")
twilioVerifyService := c.String("twilio-verify-service")
twilioCallFormat := c.String("twilio-call-format")
messageSizeLimitStr := c.String("message-size-limit")
messageDelayLimitStr := c.String("message-delay-limit")
totalTopicLimit := c.Int("global-topic-limit")
@@ -279,12 +282,14 @@ func execServe(c *cli.Context) error {
}
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
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 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)")
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || (webPushFile == "" && databaseURL == "") || webPushEmailAddress == "" || baseURL == "") {
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file (or database-url), web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if keepaliveInterval < 5*time.Second {
return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second {
@@ -320,8 +325,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 {
@@ -330,8 +335,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 {
@@ -347,6 +352,8 @@ 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 runtime.GOOS == "windows" && listenUnix != "" {
return errors.New("listen-unix is not supported on Windows")
}
// Backwards compatibility
@@ -409,6 +416,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...)
@@ -456,6 +472,7 @@ func execServe(c *cli.Context) error {
conf.TwilioAuthToken = twilioAuthToken
conf.TwilioPhoneNumber = twilioPhoneNumber
conf.TwilioVerifyService = twilioVerifyService
conf.TwilioCallFormat = twilioCallFormatTemplate
conf.MessageSizeLimit = int(messageSizeLimit)
conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit
@@ -484,6 +501,7 @@ func execServe(c *cli.Context) error {
conf.EnableMetrics = enableMetrics
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.DatabaseURL = databaseURL
conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile
@@ -491,7 +509,17 @@ func execServe(c *cli.Context) error {
conf.WebPushStartupQueries = webPushStartupQueries
conf.WebPushExpiryDuration = webPushExpiryDuration
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
conf.Version = c.App.Version
conf.BuildVersion = c.App.Version
conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)
conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)
// Check if we should run as a Windows service
if ranAsService, err := maybeRunAsService(conf); err != nil {
log.Fatal("%s", err.Error())
} else if ranAsService {
log.Info("Exiting.")
return nil
}
// Set up hot-reloading of config
go sigHandlerConfigReload(config)
@@ -507,22 +535,6 @@ func execServe(c *cli.Context) error {
return nil
}
func sigHandlerConfigReload(config string) {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGHUP)
for range sigs {
log.Info("Partially hot reloading configuration ...")
inputSource, err := newYamlSourceFromFile(config, flagsServe)
if err != nil {
log.Warn("Hot reload failed: %s", err.Error())
continue
}
if err := reloadLogLevel(inputSource); err != nil {
log.Warn("Reloading log level failed: %s", err.Error())
}
}
}
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
prefix, err := netip.ParsePrefix(host)
@@ -654,24 +666,17 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
return tokens, nil
}
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
newLevelStr, err := inputSource.String("log-level")
if err != nil {
return fmt.Errorf("cannot load log level: %s", err.Error())
func maybeFromMetadata(m map[string]any, key string) string {
if m == nil {
return ""
}
overrides, err := inputSource.StringSlice("log-level-overrides")
if err != nil {
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
v, exists := m[key]
if !exists {
return ""
}
log.ResetLevelOverrides()
if err := applyLogLevelOverrides(overrides); err != nil {
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
s, ok := v.(string)
if !ok {
return ""
}
log.SetLevel(log.ToLevel(newLevelStr))
if len(overrides) > 0 {
log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
} else {
log.Info("Log level is %v", strings.ToUpper(newLevelStr))
}
return nil
return s
}

55
cmd/serve_unix.go Normal file
View File

@@ -0,0 +1,55 @@
//go:build linux || dragonfly || freebsd || netbsd || openbsd
package cmd
import (
"os"
"os/signal"
"syscall"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/server"
)
func sigHandlerConfigReload(config string) {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGHUP)
for range sigs {
log.Info("Partially hot reloading configuration ...")
inputSource, err := newYamlSourceFromFile(config, flagsServe)
if err != nil {
log.Warn("Hot reload failed: %s", err.Error())
continue
}
if err := reloadLogLevel(inputSource); err != nil {
log.Warn("Reloading log level failed: %s", err.Error())
}
}
}
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
newLevelStr, err := inputSource.String("log-level")
if err != nil {
return err
}
overrides, err := inputSource.StringSlice("log-level-overrides")
if err != nil {
return err
}
log.ResetLevelOverrides()
if err := applyLogLevelOverrides(overrides); err != nil {
return err
}
log.SetLevel(log.ToLevel(newLevelStr))
if len(overrides) > 0 {
log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides))
} else {
log.Info("Log level is %v", newLevelStr)
}
return nil
}
func maybeRunAsService(conf *server.Config) (bool, error) {
return false, nil
}

100
cmd/serve_windows.go Normal file
View File

@@ -0,0 +1,100 @@
//go:build windows && !noserver
package cmd
import (
"fmt"
"sync"
"golang.org/x/sys/windows/svc"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/server"
)
const serviceName = "ntfy"
// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.
// Windows users can restart the service to reload configuration.
func sigHandlerConfigReload(config string) {
log.Debug("Config hot-reload via SIGHUP is not supported on Windows")
}
// runAsWindowsService runs the ntfy server as a Windows service
func runAsWindowsService(conf *server.Config) error {
return svc.Run(serviceName, &windowsService{conf: conf})
}
// windowsService implements the svc.Handler interface
type windowsService struct {
conf *server.Config
server *server.Server
mu sync.Mutex
}
// Execute is the main entry point for the Windows service
func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
status <- svc.Status{State: svc.StartPending}
// Create and start the server
var err error
s.mu.Lock()
s.server, err = server.New(s.conf)
s.mu.Unlock()
if err != nil {
log.Error("Failed to create server: %s", err.Error())
return true, 1
}
// Start server in a goroutine
serverErrChan := make(chan error, 1)
go func() {
serverErrChan <- s.server.Run()
}()
status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
log.Info("Windows service started")
for {
select {
case err := <-serverErrChan:
if err != nil {
log.Error("Server error: %s", err.Error())
return true, 1
}
return false, 0
case req := <-requests:
switch req.Cmd {
case svc.Interrogate:
status <- req.CurrentStatus
case svc.Stop, svc.Shutdown:
log.Info("Windows service stopping...")
status <- svc.Status{State: svc.StopPending}
s.mu.Lock()
if s.server != nil {
s.server.Stop()
}
s.mu.Unlock()
return false, 0
default:
log.Warn("Unexpected service control request: %d", req.Cmd)
}
}
}
}
// maybeRunAsService checks if the process is running as a Windows service,
// and if so, runs the server as a service. Returns true if it ran as a service.
func maybeRunAsService(conf *server.Config) (bool, error) {
isService, err := svc.IsWindowsService()
if err != nil {
return false, fmt.Errorf("failed to detect Windows service mode: %w", err)
} else if !isService {
return false, nil
}
log.Info("Running as Windows service")
if err := runAsWindowsService(conf); err != nil {
return true, fmt.Errorf("failed to run as Windows service: %w", err)
}
return true, nil
}

View File

@@ -3,28 +3,21 @@ package cmd
import (
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/client"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strings"
)
func init() {
commands = append(commands, cmdSubscribe)
}
const (
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
clientUserConfigFileUnixRelative = "ntfy/client.yml"
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
)
var flagsSubscribe = append(
append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
@@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
if filename != "" {
return client.LoadConfig(filename)
}
configFile, err := defaultClientConfigFile()
if err != nil {
log.Warn("Could not determine default client config file: %s", err.Error())
} else {
if s, _ := os.Stat(configFile); s != nil {
return client.LoadConfig(configFile)
if client.DefaultConfigFile != "" {
if s, _ := os.Stat(client.DefaultConfigFile); s != nil {
return client.LoadConfig(client.DefaultConfigFile)
}
log.Debug("Config file %s not found", configFile)
log.Debug("Config file %s not found", client.DefaultConfigFile)
}
log.Debug("Loading default config")
return client.NewConfig(), nil
}
//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileUnix() (string, error) {
u, err := user.Current()
if err != nil {
return "", fmt.Errorf("could not determine current user: %w", err)
}
configFile := clientRootConfigFileUnixAbsolute
if u.Uid != "0" {
homeDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("could not determine user config dir: %w", err)
}
return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
}
return configFile, nil
}
//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileWindows() (string, error) {
homeDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("could not determine user config dir: %w", err)
}
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
}
func logMessagePrefix(m *client.Message) string {
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
}

View File

@@ -1,3 +1,5 @@
//go:build darwin
package cmd
const (
@@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.`
var (
scriptLauncher = []string{"sh", "-c"}
)
func defaultClientConfigFile() (string, error) {
return defaultClientConfigFileUnix()
}

View File

@@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.`
var (
scriptLauncher = []string{"sh", "-c"}
)
func defaultClientConfigFile() (string, error) {
return defaultClientConfigFileUnix()
}

View File

@@ -1,3 +1,5 @@
//go:build windows
package cmd
const (
@@ -9,7 +11,3 @@ const (
var (
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
)
func defaultClientConfigFile() (string, error) {
return defaultClientConfigFileWindows()
}

View File

@@ -6,13 +6,14 @@ import (
"crypto/subtle"
"errors"
"fmt"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"os"
"strings"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -29,6 +30,7 @@ var flagsUser = append(
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}),
)
var cmdUser = &cli.Command{
@@ -365,24 +367,30 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access")
if authFile == "" {
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
} else if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
}
databaseURL := c.String("database-url")
authDefault, err := user.ParsePermission(authDefaultAccess)
if err != nil {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
}
authConfig := &user.Config{
Filename: authFile,
StartupQueries: authStartupQueries,
DefaultAccess: authDefault,
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
}
return user.NewManager(authConfig)
if databaseURL != "" {
pool, dbErr := db.OpenPostgres(databaseURL)
if dbErr != nil {
return nil, dbErr
}
return user.NewPostgresManager(pool, 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")
}
return user.NewSQLiteManager(authFile, authStartupQueries, 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) {

93
db/db.go Normal file
View File

@@ -0,0 +1,93 @@
package db
import (
"database/sql"
"fmt"
"net/url"
"strconv"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
)
const (
paramMaxOpenConns = "pool_max_conns"
paramMaxIdleConns = "pool_max_idle_conns"
paramConnMaxLifetime = "pool_conn_max_lifetime"
paramConnMaxIdleTime = "pool_conn_max_idle_time"
defaultMaxOpenConns = 10
)
// OpenPostgres 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 OpenPostgres(dsn string) (*sql.DB, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("invalid database URL: %w", err)
}
q := u.Query()
maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns)
if err != nil {
return nil, err
}
maxIdleConns, err := extractIntParam(q, paramMaxIdleConns, 0)
if err != nil {
return nil, err
}
connMaxLifetime, err := extractDurationParam(q, paramConnMaxLifetime, 0)
if err != nil {
return nil, err
}
connMaxIdleTime, err := extractDurationParam(q, paramConnMaxIdleTime, 0)
if err != nil {
return nil, err
}
u.RawQuery = q.Encode()
db, err := sql.Open("pgx", u.String())
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxOpenConns)
if maxIdleConns > 0 {
db.SetMaxIdleConns(maxIdleConns)
}
if connMaxLifetime > 0 {
db.SetConnMaxLifetime(connMaxLifetime)
}
if connMaxIdleTime > 0 {
db.SetConnMaxIdleTime(connMaxIdleTime)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping failed: %w", err)
}
return db, 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
}
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
}

63
db/test/test.go Normal file
View File

@@ -0,0 +1,63 @@
package dbtest
import (
"database/sql"
"fmt"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db"
"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()
setupDB, err := db.OpenPostgres(dsn)
require.Nil(t, err)
_, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
require.Nil(t, err)
require.Nil(t, setupDB.Close())
q.Set("search_path", schema)
u.RawQuery = q.Encode()
schemaDSN := u.String()
t.Cleanup(func() {
cleanDB, err := db.OpenPostgres(dsn)
if err == nil {
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
cleanDB.Close()
}
})
return schemaDSN
}
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *sql.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) *sql.DB {
t.Helper()
schemaDSN := CreateTestPostgresSchema(t)
testDB, err := db.OpenPostgres(schemaDSN)
require.Nil(t, err)
t.Cleanup(func() {
testDB.Close()
})
return testDB
}

View File

@@ -53,6 +53,16 @@ Here are a few working sample configs using a `/etc/ntfy/server.yml` file:
behind-proxy: true
```
=== "server.yml (PostgreSQL, behind proxy)"
``` yaml
base-url: "https://ntfy.example.com"
listen-http: ":2586"
database-url: "postgres://ntfy:mypassword@db.example.com:5432/ntfy?sslmode=require"
attachment-cache-dir: "/var/cache/ntfy/attachments"
behind-proxy: true
auth-default-access: "deny-all"
```
=== "server.yml (ntfy.sh config)"
``` yaml
# All the things: Behind a proxy, Firebase, cache, attachments,
@@ -125,16 +135,63 @@ using Docker Compose (i.e. `docker-compose.yml`):
command: serve
```
## Database options
ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions.
You can choose between **SQLite** and **PostgreSQL** as the database backend.
### SQLite
By default, ntfy uses SQLite with separate database files for each store. This is the simplest setup and requires
no external dependencies:
* `cache-file`: Database file for the [message cache](#message-cache).
* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth.
* `web-push-file`: Database file for [web push](#web-push) subscriptions.
### PostgreSQL (EXPERIMENTAL)
As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the
`database-url` option to a PostgreSQL connection string:
```yaml
database-url: "postgres://user:pass@host:5432/ntfy"
```
When `database-url` is set, ntfy will use PostgreSQL for the [message cache](#message-cache),
[access control](#access-control), and [web push](#web-push) subscriptions instead of SQLite. The `cache-file`,
`auth-file`, and `web-push-file` options **must not** be set in this case.
Note that setting `database-url` implicitly enables authentication and access control (equivalent to setting
`auth-file` with SQLite). The default access is `read-write`, so anonymous users can still read and write to all
topics. To restrict access, set `auth-default-access` to `deny-all` (see [access control](#access-control)).
You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`.
The database URL supports the standard [PostgreSQL connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS)
as query parameters, such as `sslmode`, `connect_timeout`, `sslcert`, `sslkey`, `sslrootcert`, and `application_name`.
See the [pgx driver documentation](https://pkg.go.dev/github.com/jackc/pgx/v5) for the full list of supported parameters.
In addition, ntfy supports the following custom query parameters to tune the connection pool:
| Parameter | Default | Description |
|---------------------------|---------|----------------------------------------------------------------------------------|
| `pool_max_conns` | 10 | Maximum number of open connections to the database |
| `pool_max_idle_conns` | - | Maximum number of idle connections in the pool |
| `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) |
| `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) |
Example:
```yaml
database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m"
```
## Message cache
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve
notifications that they may have missed.
By default, ntfy keeps messages **in-memory for 12 hours**, which means that **cached messages do not survive an application
restart**. You can override this behavior using the following config settings:
restart**. You can override this behavior by setting `cache-file` (SQLite) or `database-url` (PostgreSQL).
* `cache-file`: if set, ntfy will store messages in a SQLite based cache (default is empty, which means in-memory cache).
**This is required if you'd like messages to be retained across restarts**.
* `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`).
You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only
@@ -185,14 +242,15 @@ and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is
By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how
ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization.
ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
ntfy's auth implements two roles (`user` and `admin`) and per-topic `read` and `write` permissions using an
[access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list). Access control entries can be applied
to users as well as the special everyone user (`*`), which represents anonymous API access.
To set up auth, **configure the following options**:
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
* `auth-file` is the user/access database (SQLite); it is created automatically if it doesn't already exist; suggested
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used). Alternatively, if `database-url` is set,
auth is automatically enabled using PostgreSQL (see [database options](#database-options)).
* `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`. **If you are setting up a private instance,
you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)).
@@ -1141,12 +1199,15 @@ a database to keep track of the browser's subscriptions, and an admin email addr
- `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`
- `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)
- `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`)
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url`
(see [PostgreSQL database](#postgresql-experimental)).
Limitations:
- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_
@@ -1172,9 +1233,10 @@ web-push-file: /var/cache/ntfy/webpush.db
web-push-email-address: sysadmin@example.com
```
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 55 days,
and will automatically expire after 60 days (default). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
subscriptions are also removed automatically.
The `web-push-file` is used to store the push subscriptions in a local SQLite database. Alternatively, if `database-url`
is set, subscriptions are stored in PostgreSQL and `web-push-file` is not required. Unused subscriptions will send out
a warning after 55 days, and will automatically expire after 60 days (default). If the gateway returns an error
(e.g. 410 Gone when a user has unsubscribed), subscriptions are also removed automatically.
The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then.
@@ -1261,10 +1323,85 @@ are the easiest), and then configure the following options:
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional)
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is
rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message:
* `{{.Topic}}` is the topic name
* `{{.Message}}` is the message body
* `{{.Title}}` is the message title
* `{{.Tags}}` is a list of tags
* `{{.Priority}}` is the message priority
* `{{.Sender}}` is the IP address or username of the sender
Here's an example:
=== "Custom TwiML (English)"
``` yaml
twilio-account: "AC12345beefbeef67890beefbeef122586"
twilio-auth-token: "affebeef258625862586258625862586"
twilio-phone-number: "+18775132586"
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
twilio-call-format: |
<Response>
<Pause length="1"/>
<Say loop="3">
Yo yo yo, you should totally check out this message for {{.Topic}}.
{{ if eq .Priority 5 }}
It's really really important, dude. So listen up!
{{ end }}
<break time="1s"/>
{{ if neq .Title "" }}
Bro, it's titled: {{.Title}}.
{{ end }}
<break time="1s"/>
{{.Message}}
<break time="1s"/>
That is all.
<break time="1s"/>
You know who this message is from? It is from {{.Sender}}.
<break time="3s"/>
</Say>
<Say>See ya!</Say>
</Response>
```
=== "Custom TwiML (German)"
``` yaml
twilio-account: "AC12345beefbeef67890beefbeef122586"
twilio-auth-token: "affebeef258625862586258625862586"
twilio-phone-number: "+18775132586"
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
twilio-call-format: |
<Response>
<Pause length="1"/>
<Say loop="3" voice="alice" language="de-DE">
Du hast eine Nachricht zum Thema {{.Topic}}.
{{ if eq .Priority 5 }}
Achtung. Die Nachricht ist sehr wichtig.
{{ end }}
<break time="1s"/>
{{ if neq .Title "" }}
Titel der Nachricht: {{.Title}}.
{{ end }}
<break time="1s"/>
Nachricht:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
Ende der Nachricht.
<break time="1s"/>
Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
<break time="3s"/>
</Say>
<Say voice="alice" language="de-DE">Alla mol!</Say>
</Response>
```
## Message limits
There are a few message limits that you can configure:
@@ -1680,12 +1817,13 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). |
| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [database options](#database-options). |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) |
| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) |
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). |
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |

View File

@@ -340,10 +340,6 @@ Then either follow the steps for building with or without Firebase.
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
if you're self-hosting the server. Then run:
```
# Remove Google dependencies (FCM)
sed -i -e '/google-services/d' build.gradle
sed -i -e '/google-services/d' app/build.gradle
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
./gradlew assembleFdroidRelease
@@ -351,6 +347,8 @@ sed -i -e '/google-services/d' app/build.gradle
./gradlew bundleFdroidRelease
```
The F-Droid flavor automatically excludes Google Services dependencies.
### Build Play flavor (FCM)
!!! info
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will

View File

@@ -661,6 +661,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 +694,4 @@ To test failure notifications:
false; alert # Always fails (exit 1)
ls --invalid; alert # Invalid option
cat nonexistent_file; alert # File not found
```
```

View File

@@ -4,7 +4,7 @@ or POST requests. I use it to notify myself when scripts fail, or long-running c
## Step 1: Get the app
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.svg"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="static/img/badge-appstore.png"></a>
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.

View File

@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
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
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
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
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
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
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
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
sudo ntfy serve
```
@@ -71,7 +71,7 @@ deb/rpm packages.
The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely
go away soon. I suspect I will phase it out some time in early 2026.
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 6B7C CFDB 962D 4F1E C4AF`):
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 B6B7 CFDB 962D 4F1E C4AF`):
=== "x86_64/amd64"
```bash
@@ -116,7 +116,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -124,7 +124,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -132,7 +132,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,7 +140,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -150,33 +150,35 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.15.0/ntfy_2.15.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.15.0/ntfy_2.15.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.15.0/ntfy_2.15.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
## Arch Linux
<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>
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
build and install ntfy and keep it up to date.
@@ -191,7 +193,9 @@ cd ntfysh-bin
makepkg -si
```
## NixOS / Nix
## NixOS / Nix
<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>
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
```
nix-env -iA ntfy-sh
@@ -199,20 +203,28 @@ nix-env -iA ntfy-sh
NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy).
## FreeBSD
<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>
ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`:
```
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.15.0/ntfy_2.15.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.17.0/ntfy_2.17.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.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
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
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.17.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -221,6 +233,8 @@ ntfy --help
development as well. Check out the [build instructions](develop.md) for details.
## Homebrew
<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>
To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),
simply run:
```
@@ -228,19 +242,29 @@ brew install ntfy
```
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
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),
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`
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
Once installed, you can run the ntfy CLI commands like so:
Also available in [Scoop's](https://scoop.sh) Main repository:
```
ntfy.exe -h
```
`scoop install ntfy`
The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`)
for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually.
!!! info
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an
elevated command prompt (adjust the path to `ntfy.exe` accordingly):
```
sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto
sc start ntfy
```
## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
@@ -543,18 +567,18 @@ kubectl apply -k /ntfy
cpu: 150m
memory: 150Mi
volumeMounts:
- mountPath: /etc/ntfy
subPath: server.yml
name: config-volume # generated vie configMapGenerator from kustomization file
- mountPath: /var/cache/ntfy
name: cache-volume #cache volume mounted to persistent volume
volumes:
- name: config-volume
configMap: # uses configmap generator to parse server.yml to configmap
name: server-config
- name: cache-volume
persistentVolumeClaim: # stores /cache/ntfy in defined pv
claimName: ntfy-pvc
- mountPath: /etc/ntfy/server.yml
subPath: server.yml
name: config-volume # generated via configMapGenerator from kustomization file
- mountPath: /var/cache/ntfy
name: cache-volume # cache volume mounted to persistent volume
volumes:
- name: config-volume
configMap: # uses configmap generator to parse server.yml to configmap
name: server-config
- name: cache-volume
persistentVolumeClaim: # stores /cache/ntfy in defined pv
claimName: ntfy-pvc
```
=== "ntfy-pvc.yaml"

View File

@@ -90,7 +90,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
- [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
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
@@ -182,6 +182,10 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)
- [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
## Blog + forum posts

File diff suppressed because one or more lines are too long

View File

@@ -6,12 +6,125 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.15.0 | Nov 16, 2025 |
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
| ntfy server | v2.17.0 | Feb 8, 2026 |
| ntfy Android app | v1.23.0 | Deb 22, 2026 |
| ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
## 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
This release adds support for templating in the priority field, a new "copy" action button to copy values to the clipboard,
a red notification dot on the favicon for unread messages, and an admin-only version endpoint. It also includes several
crash fixes, web app improvements, and documentation updates.
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`),
or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy will always remain open source.
**Features:**
* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
* Server: Add admin-only `GET /v1/version` endpoint returning server version, build commit, and date ([#1599](https://github.com/binwiederhier/ntfy/issues/1599), thanks to [@crivchri](https://github.com/crivchri) for reporting)
* Server/Web: [Support "copy" action](publish.md#copy-to-clipboard) 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)
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
**Bug fixes + maintenance:**
* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598))
* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
* Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
* Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)
* Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)
* Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)
* Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)
* Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
## ntfy Android app v1.22.2
Released January 20, 2026
This release adds support for [updating and deleting notifications](publish.md#updating-deleting-notifications) (requires server v2.16.0),
as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates),
and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting).
<div id="v1221-screenshots-1" class="screenshots">
<a href="../../static/img/android-screenshot-notification-update-1.png"><img src="../../static/img/android-screenshot-notification-update-1.png"/></a>
<a href="../../static/img/android-screenshot-notification-update-2.png"><img src="../../static/img/android-screenshot-notification-update-2.png"/></a>
</div>
<div id="v1221-screenshots-2" class="screenshots">
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
</div>
**Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
for the initial implementation)
* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
[#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
* Connection error dialog to help diagnose connection issues
**Bug fixes + maintenance:**
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
* Fix crash when exiting multi-delete in detail view
* Fix potential crashes with icon downloader and backuper
## ntfy server v2.16.0
Released January 19, 2026
This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
This one is very exciting, as it brings a lot of highly requested features to ntfy.
**Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
thanks to [@wtf911](https://github.com/wtf911))
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
## ntfy Android app v1.21.1
Released January 6, 2026
@@ -19,6 +132,13 @@ This is the first feature release in a long time. After all the SDK updates, fix
and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
support for passing headers to your proxy, an in-app language switcher, and more.
<div id="v1211-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
</div>
If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
@@ -1599,32 +1719,18 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.16.x (UNRELEASED)
### ntfy server v2.18.x (UNRELEASED)
**Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
for the initial implementation)
### ntfy Android app v1.22.x (UNRELEASED)
**Features:**
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
for the initial implementation)
* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
[#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
* Connection error dialog to help diagnose connection issues
* Add experimental [PostgreSQL support](config.md#postgresql-experimental) 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), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
**Bug fixes + maintenance:**
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
* Fix crash when exiting multi-delete in detail view
* Fix potential crashes with icon downloader and backuper
* 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.x (UNRELEASED)
**Bug fixes + maintenance:**
* Fix crash in settings when fragment is detached during backup/restore or log operations

View File

@@ -92,7 +92,7 @@ figure video {
}
.screenshots img {
max-height: 230px;
max-height: 350px;
max-width: 350px;
margin: 3px;
border-radius: 5px;
@@ -214,3 +214,30 @@ figure video {
font-weight: 400;
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
}
/* Community maintained badge */
.community-badge {
display: inline-flex;
align-items: center;
gap: 0.35em;
background-color: rgba(51, 133, 116, 0.1);
border: 1px solid rgba(51, 133, 116, 0.3);
border-radius: 0.7em;
padding: 0.1em 0.7em;
font-size: 0.75rem;
color: #338574;
margin-top: 0;
margin-bottom: 0.5em;
}
.community-badge svg {
width: 1em;
height: 1em;
fill: currentColor;
}
body[data-md-color-scheme="slate"] .community-badge {
background-color: rgba(86, 189, 168, 0.15);
border-color: rgba(86, 189, 168, 0.4);
color: #56bda8;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

240
docs/static/img/badge-fdroid.svg vendored Normal file
View File

@@ -0,0 +1,240 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="43 43 560 164"
version="1.1"
id="svg78"
sodipodi:docname="get-it-on-en.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview80"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<defs
id="defs8">
<radialGradient
xlink:href="#a"
id="b"
cx="113"
cy="-12.89"
r="59.662"
fx="113"
fy="-12.89"
gradientTransform="matrix(0 1.96105 -1.97781 0 254.507 78.763)"
gradientUnits="userSpaceOnUse" />
<linearGradient
id="a">
<stop
offset="0"
style="stop-color:#fff;stop-opacity:.09803922"
id="stop3" />
<stop
offset="1"
style="stop-color:#fff;stop-opacity:0"
id="stop5" />
</linearGradient>
</defs>
<g
transform="translate(-289,-312.362)"
id="g76">
<path
id="rect10"
style="display:inline;overflow:visible;stroke:#a6a6a6;stroke-width:4;marker:none"
d="m 352,355.362 h 520 c 11.08,0 20,8.92 20,20 v 124 c 0,11.08 -8.92,20 -20,20 H 352 c -11.08,0 -20,-8.92 -20,-20 v -124 c 0,-11.08 8.92,-20 20,-20 z" />
<g
aria-label="GET IT ON"
id="text14"
style="font-size:12.3952px;line-height:100%;font-family:'DejaVu Sans';-inkscape-font-specification:'DejaVu Sans';letter-spacing:0;word-spacing:0;display:inline;overflow:visible;fill:#ffffff;stroke-width:1px;marker:none">
<path
d="m 529.2627,398.81787 v -6.6817 h -5.49866 v -2.76599 h 8.83117 v 10.68072 q -1.94952,1.383 -4.29895,2.09949 -2.34942,0.69983 -5.01544,0.69983 -5.83191,0 -9.1311,-3.39917 -3.28253,-3.41583 -3.28253,-9.49768 0,-6.09851 3.28253,-9.49768 3.29919,-3.41583 9.1311,-3.41583 2.43274,0 4.61554,0.59985 2.19947,0.59985 4.04901,1.76623 v 3.58246 q -1.86621,-1.58294 -3.96569,-2.38275 -2.09949,-0.7998 -4.41559,-0.7998 -4.56555,0 -6.86499,2.54937 -2.28278,2.54938 -2.28278,7.59815 0,5.0321 2.28278,7.58148 2.29944,2.54938 6.86499,2.54938 1.78289,0 3.18255,-0.29993 1.39966,-0.31659 2.51606,-0.96643 z"
style="font-size:34.125px"
id="path83" />
<path
d="m 538.74371,377.48975 h 15.7295 v 2.83264 h -12.36365 v 7.36487 h 11.84711 v 2.83264 h -11.84711 v 9.01446 h 12.66357 v 2.83264 h -16.02942 z"
style="font-size:34.125px"
id="path85" />
<path
d="m 556.85596,377.48975 h 21.04486 v 2.83264 h -8.83118 V 402.367 h -3.38251 v -22.04461 h -8.83117 z"
style="font-size:34.125px"
id="path87" />
<path
d="m 591.99738,377.48975 h 3.36584 V 402.367 h -3.36584 z"
style="font-size:34.125px"
id="path89" />
<path
d="m 598.61243,377.48975 h 21.04486 v 2.83264 h -8.83118 V 402.367 h -3.38251 v -22.04461 h -8.83117 z"
style="font-size:34.125px"
id="path91" />
<path
d="m 643.85138,379.77252 q -3.66577,0 -5.83191,2.73267 -2.14947,2.73266 -2.14947,7.44818 0,4.69885 2.14947,7.43152 2.16614,2.73266 5.83191,2.73266 3.66577,0 5.79858,-2.73266 2.14948,-2.73267 2.14948,-7.43152 0,-4.71552 -2.14948,-7.44818 -2.13281,-2.73267 -5.79858,-2.73267 z m 0,-2.73266 q 5.23206,0 8.36462,3.5158 3.13257,3.49915 3.13257,9.39771 0,5.8819 -3.13257,9.3977 -3.13256,3.49915 -8.36462,3.49915 -5.24872,0 -8.39795,-3.49915 -3.13257,-3.49914 -3.13257,-9.3977 0,-5.89856 3.13257,-9.39771 3.14923,-3.5158 8.39795,-3.5158 z"
style="font-size:34.125px"
id="path93" />
<path
d="m 660.61395,377.48975 h 4.53223 l 11.03064,20.81158 v -20.81158 h 3.26587 V 402.367 h -4.53223 L 663.87982,381.55542 V 402.367 h -3.26587 z"
style="font-size:34.125px"
id="path95" />
</g>
<g
aria-label="F-Droid"
id="text18"
style="font-weight:700;font-size:29.7088px;line-height:100%;font-family:Rokkitt;-inkscape-font-specification:'Rokkitt Bold';letter-spacing:0;word-spacing:0;display:inline;overflow:visible;fill:#ffffff;stroke-width:1px;marker:none">
<path
d="m 510.81067,481.24332 v 8.11767 h 27.97119 v -8.11767 l -7.23633,-1.3916 v -18.55469 h 23.65723 v -10.43701 h -23.65723 v -18.60108 h 22.03369 l 0.60303,8.07129 h 10.39063 v -18.5083 h -53.76221 v 8.16406 l 7.18994,1.3916 v 48.47413 z"
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
id="path98" />
<path
d="m 599.13098,465.70377 v -10.43702 h -26.16211 v 10.43702 z"
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
id="path100" />
<path
d="m 637.67834,421.82193 h -30.3833 v 8.16406 l 7.18995,1.3916 v 48.47413 l -7.18995,1.3916 v 8.11767 h 30.3833 c 16.51368,0 28.43506,-11.59668 28.43506,-28.15674 v -11.1792 c 0,-16.51367 -11.92138,-28.20312 -28.43506,-28.20312 z m -9.64843,10.43701 h 8.95263 c 9.69483,0 15.53955,7.23633 15.53955,17.67334 v 11.27197 c 0,10.57618 -5.84472,17.76612 -15.53955,17.76612 h -8.95263 z"
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
id="path102" />
<path
d="m 674.09192,481.24332 v 8.11767 h 26.5332 v -8.11767 l -6.49414,-1.3916 v -24.58497 c 1.48438,-2.82959 3.89649,-4.31396 7.88574,-4.12841 l 6.67969,0.3247 1.43799,-12.47802 c -1.29883,-0.46387 -3.43262,-0.74219 -5.33447,-0.74219 -4.87061,0 -8.62793,3.06152 -10.99366,8.25683 l -0.0928,-1.11328 -0.51025,-6.21582 h -19.80713 v 8.16407 l 7.18994,1.3916 v 31.12549 z"
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
id="path104" />
<path
d="m 713.24231,463.80191 v 0.97412 c 0,15.07569 8.85986,25.55908 23.75,25.55908 14.70459,0 23.61084,-10.48339 23.61084,-25.55908 v -0.97412 c 0,-15.0293 -8.85986,-25.55908 -23.70361,-25.55908 -14.79737,0 -23.65723,10.57617 -23.65723,25.55908 z m 13.54492,0.97412 v -0.97412 c 0,-8.90625 3.06152,-15.12207 10.11231,-15.12207 7.05078,0 10.20507,6.21582 10.20507,15.12207 v 0.97412 c 0,9.0918 -3.10791,15.16846 -10.1123,15.16846 -7.18994,0 -10.20508,-6.03027 -10.20508,-15.16846 z"
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
id="path106" />
<path
d="M 786.16223,428.548 V 416.99771 H 772.15344 V 428.548 Z m -20.08545,52.69532 v 8.11767 h 26.57959 v -8.11767 l -6.49414,-1.3916 v -40.68116 h -20.78125 v 8.16407 l 7.23633,1.3916 v 31.12549 z"
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
id="path108" />
<path
d="m 829.76575,483.23795 1.0205,6.12304 h 18.22999 v -8.11767 l -6.49415,-1.3916 v -62.85401 h -20.78125 v 8.16406 l 7.23633,1.39161 v 17.99804 c -3.01513,-4.03564 -7.05078,-6.30859 -12.06054,-6.30859 -12.43164,0 -19.62159,10.62256 -19.62159,26.44043 v 0.97412 c 0,14.84375 7.14356,24.67773 19.52881,24.67773 5.52002,0 9.7876,-2.45849 12.9419,-7.09716 z m -18.92578,-17.58057 v -0.97412 c 0,-9.46289 2.87597,-15.91065 9.50927,-15.91065 3.89649,0 6.77246,1.85547 8.62793,5.05616 v 21.2915 c -1.85547,3.01514 -4.77783,4.68506 -8.7207,4.68506 -6.67969,0 -9.4165,-5.38086 -9.4165,-14.14795 z"
style="font-size:95px;line-height:100%;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab Bold'"
id="path110" />
</g>
<path
d="m 2.589,1006.862 4.25,5.5"
style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
id="path20" />
<path
d="m 2.611,1005.61 c -0.453,0.011 -0.761,0.188 -0.98,0.448 2.027,2.409 2.368,2.792 5.135,6.221 1.02,1.32 2.082,0.638 1.062,-0.681 l -4.25,-5.5 a 1.24,1.24 0 0 0 -0.967,-0.489"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.298039;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
id="path22" />
<path
d="m 1.622,1006.07 a 1.25,1.25 0 0 0 -0.022,1.557 l 4.25,5.5 c 1.02,1.319 1.15,-0.613 1.15,-0.613 0,0 -3.735,-4.51 -5.378,-6.443"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
id="path24" />
<path
d="m 2.338,1005.844 c -0.438,0 -0.96,0.142 -0.824,0.799 0.103,0.501 4.66,6.074 4.66,6.074 1.02,1.32 2.494,0.677 1.474,-0.642 l -4.234,-5.473 c -0.26,-0.29 -0.608,-0.744 -1.076,-0.758"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
transform="matrix(-2.63159,0,0,2.63157,483.158,-2270.475)"
id="path26" />
<path
d="m 2.589,1006.862 4.25,5.5"
style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
id="path28" />
<path
d="m 2.611,1005.61 c -0.453,0.011 -0.761,0.188 -0.98,0.448 2.027,2.409 2.368,2.792 5.135,6.221 1.02,1.32 2.082,0.638 1.062,-0.681 l -4.25,-5.5 a 1.24,1.24 0 0 0 -0.967,-0.489"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.298039;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
id="path30" />
<path
d="m 1.622,1006.07 a 1.25,1.25 0 0 0 -0.022,1.557 l 4.25,5.5 c 1.02,1.319 1.15,-0.613 1.15,-0.613 0,0 -3.735,-4.51 -5.378,-6.443"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
id="path32" />
<path
d="m 2.338,1005.844 c -0.438,0 -0.96,0.142 -0.824,0.799 0.103,0.501 4.66,6.074 4.66,6.074 1.02,1.32 2.494,0.677 1.474,-0.642 l -4.234,-5.473 c -0.26,-0.29 -0.608,-0.744 -1.076,-0.758"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.475)"
id="path34" />
<g
transform="matrix(2.63159,0,0,2.63157,467.369,-2270.475)"
id="g44">
<path
id="rect36"
style="opacity:1;fill:#aeea00;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m -34,1010.36 h 32 c 1.662,0 3,1.338 3,3 v 6.92 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -6.92 c 0,-1.662 1.338,-3 3,-3 z" />
<path
id="rect38"
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m -34,1013.279 h 32 c 1.662,0 3,1.338 3,3 v 4 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -4 c 0,-1.662 1.338,-3 3,-3 z" />
<path
id="rect40"
style="opacity:1;fill:#ffffff;fill-opacity:0.298039;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m -34,1010.362 h 32 c 1.662,0 3,1.338 3,3 v 4 c 0,1.662 -1.338,3 -3,3 h -32 c -1.662,0 -3,-1.338 -3,-3 v -4 c 0,-1.662 1.338,-3 3,-3 z" />
<path
id="rect42"
style="opacity:1;fill:#aeea00;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m -34,1011.5 h 32 c 1.662,0 3,1.0954 3,2.456 v 5.729 c 0,1.3606 -1.338,2.456 -3,2.456 h -32 c -1.662,0 -3,-1.0954 -3,-2.456 v -5.729 c 0,-1.3606 1.338,-2.456 3,-2.456 z" />
</g>
<g
transform="matrix(2.63159,0,0,2.63157,356.842,-2270.745)"
id="g54">
<path
id="rect46"
style="opacity:1;fill:#1976d2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m 8,1024.522 h 32 c 1.662,0 3,1.338 3,3 v 19.84 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -19.84 c 0,-1.662 1.338,-3 3,-3 z" />
<path
id="rect48"
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m 8,1037.3621 h 32 c 1.662,0 3,1.338 3,3 v 7 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -7 c 0,-1.662 1.338,-3 3,-3 z" />
<path
id="rect50"
style="opacity:1;fill:#ffffff;fill-opacity:0.2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m 8,1024.442 h 32 c 1.662,0 3,1.338 3,3 v 7 c 0,1.662 -1.338,3 -3,3 H 8 c -1.662,0 -3,-1.338 -3,-3 v -7 c 0,-1.662 1.338,-3 3,-3 z" />
<path
id="rect52"
style="opacity:1;fill:#1976d2;stroke-width:3;stroke-linecap:round;stroke-miterlimit:3"
d="m 8,1025.662 h 32 c 1.662,0 3,1.2122 3,2.718 v 18.124 c 0,1.5058 -1.338,2.718 -3,2.718 H 8 c -1.662,0 -3,-1.2122 -3,-2.718 v -18.124 c 0,-1.5058 1.338,-2.718 3,-2.718 z" />
</g>
<g
transform="matrix(2.63159,0,0,2.63157,356.842,396.264)"
id="g60">
<path
d="m 24,17.75 c -2.88,0 -5.32,1.985 -6.033,4.65 H 21.18 A 3.22,3.22 0 0 1 24,20.75 3.23,3.23 0 0 1 27.25,24 3.23,3.23 0 0 1 24,27.25 3.22,3.22 0 0 1 21.07,25.4 h -3.154 c 0.642,2.766 3.132,4.85 6.084,4.85 3.434,0 6.25,-2.816 6.25,-6.25 0,-3.434 -2.816,-6.25 -6.25,-6.25"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#0d47a1;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
id="path56" />
<path
id="circle58"
style="opacity:1;fill:none;fill-opacity:0.403922;stroke:#0d47a1;stroke-width:1.9;stroke-linecap:round"
d="M 33.55,24 A 9.5500002,9.5500002 0 0 1 24,33.55 9.5500002,9.5500002 0 0 1 14.45,24 9.5500002,9.5500002 0 0 1 24,14.45 9.5500002,9.5500002 0 0 1 33.55,24 Z" />
</g>
<g
transform="matrix(2.63159,0,0,2.63157,356.842,-2269.159)"
id="g66">
<path
id="ellipse62"
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
d="m 17.75,1016.487 a 3.375,3.875 0 0 1 -3.375,3.875 3.375,3.875 0 0 1 -3.375,-3.875 3.375,3.875 0 0 1 3.375,-3.875 3.375,3.875 0 0 1 3.375,3.875 z" />
<path
id="circle64"
style="opacity:1;fill:#ffffff;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
d="m 17.75,1016.987 a 3.375,3.375 0 0 1 -3.375,3.375 3.375,3.375 0 0 1 -3.375,-3.375 3.375,3.375 0 0 1 3.375,-3.375 3.375,3.375 0 0 1 3.375,3.375 z" />
</g>
<g
transform="matrix(2.63159,0,0,2.63157,408.158,-2269.159)"
id="g72">
<path
id="ellipse68"
style="opacity:1;fill:#263238;fill-opacity:0.2;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
d="m 17.75,1016.487 a 3.375,3.875 0 0 1 -3.375,3.875 3.375,3.875 0 0 1 -3.375,-3.875 3.375,3.875 0 0 1 3.375,-3.875 3.375,3.875 0 0 1 3.375,3.875 z" />
<path
id="circle70"
style="opacity:1;fill:#ffffff;stroke-width:1.9;stroke-linecap:round;stroke-opacity:0.697211"
d="m 17.75,1016.987 a 3.375,3.375 0 0 1 -3.375,3.375 3.375,3.375 0 0 1 -3.375,-3.375 3.375,3.375 0 0 1 3.375,-3.375 3.375,3.375 0 0 1 3.375,3.375 z" />
</g>
<path
d="m 282.715,299.835 a 3.29,3.29 0 0 0 -2.662,5.336 l 9.474,12.261 A 7.9,7.9 0 0 0 289,320.257 v 18.21 a 7.877,7.877 0 0 0 7.895,7.895 h 84.21 A 7.877,7.877 0 0 0 389,338.468 v -18.211 c 0,-0.999 -0.19,-1.949 -0.525,-2.826 l 9.472,-12.26 a 3.29,3.29 0 0 0 -2.433,-5.334 3.29,3.29 0 0 0 -2.772,1.31 l -9.013,11.666 a 7.9,7.9 0 0 0 -2.624,-0.45 h -84.21 c -0.922,0 -1.8,0.163 -2.622,0.45 l -9.015,-11.666 a 3.29,3.29 0 0 0 -2.543,-1.312 m 14.18,49.527 A 7.877,7.877 0 0 0 289,357.257 v 52.21 a 7.877,7.877 0 0 0 7.895,7.895 h 84.21 A 7.877,7.877 0 0 0 389,409.468 v -52.211 a 7.877,7.877 0 0 0 -7.895,-7.895 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:400;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#b);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:6.57895;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto"
transform="translate(81,76)"
id="path74" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -5,7 +5,7 @@ on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https
contribute, or [build your own](../develop.md).
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="../../static/img/badge-googleplay.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.svg"></a>
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
@@ -82,9 +82,8 @@ you'll see as a permanent notification that looks like this:
<figcaption>Instant delivery foreground notification</figcaption>
</figure>
Android does not allow you to dismiss this notification, unless you turn off the notification channel in the settings.
To do so, long-press on the foreground notification (screenshot above) and navigate to the settings. Then toggle the
"Subscription Service" off:
To turn off this notification, long-press on the foreground notification (screenshot above) and navigate to the
settings. Then toggle the "Subscription Service" off:
<figure markdown>
![foreground service](../static/img/notification-settings.png){ width=500 }
@@ -102,6 +101,28 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.
!!! info "F-Droid: Always instant delivery"
Since the F-Droid build does not include Firebase, **all subscriptions use instant delivery by default**, and
there is no option to disable it. The F-Droid app hides all mentions of "instant delivery" in the UI, since
showing options that can't be changed would only be confusing.
## Publishing messages
_Supported on:_ :material-android:
The Android app allows you to **publish messages directly from the app**, without needing to use curl or any other
tool. When enabled in the settings (Settings → General → Show message bar), a **message bar** appears at the bottom
of the topic view (it's enabled by default). You can type a message and tap the send button to publish it instantly.
If the message bar is disabled, you can tap the floating action button (FAB) at the bottom right instead.
For more options, tap the expand button next to the send button to open the full **publish dialog**. The dialog lets
you compose a full notification with all available options, including title, tags, priority, click URL, email
forwarding, delayed delivery, attachments, Markdown formatting, and phone calls.
<div id="publish-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
</div>
## Share to topic
_Supported on:_ :material-android:
@@ -135,6 +156,67 @@ or to simply directly link to a topic from a mobile website.
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?display=<name>`</span> | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. |
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
## Advanced settings
### Custom headers
_Supported on:_ :material-android:
If your ntfy server is behind an **authenticated proxy or tunnel** (e.g., Cloudflare Access, Tailscale Funnel, or
a reverse proxy with basic auth), you can configure custom HTTP headers that will be sent with every request to
that server. You could set headers such as `Authorization`, `CF-Access-Client-Id`, or any other headers required by
your setup. To add custom headers, go to **Settings → Advanced → Custom headers**.
<div id="custom-headers-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
<a href="../../static/img/android-screenshot-custom-headers-add.jpg"><img src="../../static/img/android-screenshot-custom-headers-add.jpg"/></a>
</div>
!!! warning
If you have a user configured for a server, you cannot add an `Authorization` header for that server, as ntfy
sets this header automatically. Similarly, if you have a custom `Authorization` header, you cannot add a user
for that server.
### Manage certificates
_Supported on:_ :material-android:
If you're running a self-hosted ntfy server with a **self-signed certificate** or need to use **mutual TLS (mTLS)**
for client authentication, you can manage certificates in the app settings.
Go to **Settings → Advanced → Manage certificates** to:
- **Add trusted certificates**: Import a server certificate (PEM format) to trust when connecting to your ntfy server.
This is useful for self-signed certificates that are not trusted by the Android system.
- **Add client certificates**: Import a client certificate (PKCS#12 format) for mutual TLS authentication. This
certificate will be presented to the server when connecting.
When you subscribe to a topic on a server with an untrusted certificate, the app will show a security warning and
allow you to review and trust the certificate.
<div id="certificates-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
</div>
### Language
_Supported on:_ :material-android:
The Android app supports many languages and uses the **system language by default**. If you'd like to use the app in
a different language than your system, you can override it in **Settings → General → Language**.
<div id="language-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
<a href="../../static/img/android-screenshot-language-german.jpg"><img src="../../static/img/android-screenshot-language-german.jpg"/></a>
<a href="../../static/img/android-screenshot-language-hebrew.jpg"><img src="../../static/img/android-screenshot-language-hebrew.jpg"/></a>
<a href="../../static/img/android-screenshot-language-chinese.jpg"><img src="../../static/img/android-screenshot-language-chinese.jpg"/></a>
</div>
The app currently supports over 30 languages, including English, German, French, Spanish, Chinese, Japanese, and many
more. Languages with more than 80% of strings translated are shown in the language picker.
!!! tip "Help translate ntfy"
If you'd like to help translate ntfy into your language or improve existing translations, please visit the
[ntfy Weblate project](https://hosted.weblate.org/projects/ntfy/). Contributions are very welcome!
## Integrations
### UnifiedPush
@@ -168,10 +250,13 @@ Here's an example using [MacroDroid](https://play.google.com/store/apps/details?
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
broadcasts is supported:
<div id="integration-screenshots-receive" class="screenshots">
<div id="integration-screenshots-receive-1" class="screenshots">
<a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a>
<a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a>
</div>
<div id="integration-screenshots-receive-2" class="screenshots">
<a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a>
<a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a>
<a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a>
@@ -239,3 +324,29 @@ The following intent extras are supported when for the intent with the `io.hecke
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
## Troubleshooting
### Connection error dialog
_Supported on:_ :material-android:
If the app has trouble connecting to a ntfy server, a **warning icon** will appear in the app bar. Tapping it opens
the **connection error dialog**, which shows detailed information about the connection problem and helps you diagnose
the issue.
<div id="connection-error-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-connection-error-warning.jpg"><img src="../../static/img/android-screenshot-connection-error-warning.jpg"/></a>
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
</div>
Common connection errors include:
| Error | Description |
|-------|-------------|
| Connection refused | The server may be down or the address may be incorrect |
| WebSocket not supported | The server may not support WebSocket connections, or a proxy is blocking them |
| Not authorized (401/403) | Username/password may be incorrect, or access credentials have expired |
| Certificate not trusted | The server is using a self-signed certificate (see [Manage certificates](#manage-certificates)) |
If you're having persistent connection issues, you can also check the app logs under **Settings → Advanced → Record logs**
and share them for debugging.

257
docs/terms.md Normal file
View File

@@ -0,0 +1,257 @@
# Terms of Service
**Last updated:** January 26, 2026
Please read these Terms of Service ("Terms") carefully before using the ntfy.sh website and service (the "Service")
operated by ntfy LLC ("us", "we", or "our").
Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These
Terms apply to all visitors, users, and others who access or use the Service.
**By accessing or using the Service, you agree to be bound by these Terms. If you disagree with any part of the
Terms, you may not access the Service.**
## Service description
ntfy (pronounced "notify") is a simple HTTP-based pub-sub notification service. It allows you to send push
notifications to your phone or desktop via scripts from any computer, using a REST API. The Service includes:
- The ntfy.sh hosted server
- The ntfy web application
- The ntfy mobile applications (Android and iOS)
- The ntfy command-line interface (CLI)
The server software and mobile applications are open source and can be [self-hosted](install.md). These Terms
apply specifically to the ntfy.sh hosted service.
## Subscriptions and billing
### Free tier
You may use the Service without creating an account or subscribing to a paid plan. Free usage is subject to
rate limits and other restrictions as described in our documentation.
### Paid plans
Some features of the Service are available only through paid subscription plans ("Subscriptions"). You will
be billed in advance on a recurring basis ("Billing Cycle"). Billing cycles are available on a monthly or
annual basis.
At the end of each Billing Cycle, your Subscription will automatically renew under the same conditions unless
you cancel it or we cancel it. You may cancel your Subscription renewal through your account settings in the
web application.
A valid payment method is required to process payment for your Subscription. You shall provide us with accurate
and complete billing information. By submitting such payment information, you authorize us to charge all
Subscription fees incurred through your account to your payment method.
Payment processing is handled by Stripe. Your payment information is subject to Stripe's
[privacy policy](https://stripe.com/privacy) and [terms of service](https://stripe.com/legal).
Should automatic billing fail to occur for any reason, we will retry the payment according to Stripe's retry
schedule. If payment continues to fail after multiple attempts, your Subscription will be canceled and your
account will revert to the free tier.
### Fee changes
We may, in our sole discretion and at any time, modify the Subscription fees for paid plans. Any fee change
will become effective at the end of the then-current Billing Cycle.
We will provide you with reasonable prior notice of any change in Subscription fees to give you an opportunity
to cancel your Subscription before such change becomes effective.
Your continued use of the Service after a fee change comes into effect constitutes your agreement to pay the
modified Subscription fee.
## Refunds
Refund requests for Subscriptions may be considered on a case-by-case basis and granted at the sole discretion
of ntfy LLC. To request a refund, please contact us at [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh).
## User accounts
When you create an account with us, you must provide information that is accurate, complete, and current at
all times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of
your account.
You are responsible for:
- Safeguarding the password that you use to access the Service
- Any activities or actions under your account, whether your password is with our Service or a third-party service
- Keeping your account credentials confidential
You agree not to disclose your password to any third party. You must notify us immediately upon becoming aware
of any breach of security or unauthorized use of your account.
You represent that you are at least 18 years old, or that you are at least the minimum age required to form
a binding contract in your jurisdiction, and have the legal authority to enter into these Terms.
## Acceptable use
You agree not to use the Service to:
- Send spam, unsolicited messages, or messages to recipients who have not consented to receive them
- Distribute malware, viruses, or any other malicious software
- Transmit illegal content or content that violates the rights of others
- Harass, abuse, or harm another person or group
- Impersonate any person or entity, or falsely state or misrepresent your affiliation with a person or entity
- Interfere with or disrupt the Service or servers or networks connected to the Service
- Attempt to gain unauthorized access to the Service, other accounts, or computer systems
- Use the Service for any illegal purpose or in violation of any applicable laws or regulations
- Circumvent rate limits or other technical restrictions
- Use the Service in a manner that could reasonably be expected to impose an unreasonable or disproportionately
large load on our infrastructure
We reserve the right to investigate and take appropriate action against anyone who, in our sole discretion,
violates this provision, including removing content, terminating accounts, and reporting to law enforcement.
### Topic names
Topic names on ntfy.sh are public. If you use the Service without access controls, your topic name functions
as a password. You are responsible for choosing topic names that cannot be easily guessed. We are not responsible
for any unauthorized access to messages published to easily guessable topic names.
For reserved topics and access control features, consider subscribing to a paid plan.
## Intellectual property
### Open source software
The ntfy server, web application, and mobile applications are open source software, dual-licensed under the
[Apache License 2.0](https://github.com/binwiederhier/ntfy/blob/main/LICENSE) and
[GPLv2](https://github.com/binwiederhier/ntfy/blob/main/LICENSE.GPLv2). You are free to use, modify, and
distribute the software in accordance with these licenses.
### Trademarks
The ntfy name, logo, and branding are trademarks of ntfy LLC. Our trademarks may not be used in connection
with any product or service without our prior written consent.
### Your content
You retain ownership of any content you transmit through the Service. By using the Service, you grant us a
limited license to process and transmit your content solely for the purpose of providing the Service.
## Service availability
The Service is provided on a "best effort" basis. We do not guarantee any specific uptime or availability.
We strive to maintain high availability, but the Service may be interrupted for maintenance, updates, or
due to circumstances beyond our control. We will make reasonable efforts to notify users of planned
maintenance when possible.
For applications requiring guaranteed uptime or specific service level agreements, we recommend
[self-hosting your own ntfy server](install.md).
A [status page](https://ntfy.statuspage.io/) is available to check the current operational status of the Service.
## Third-party services
The Service relies on third-party services to provide certain functionality:
- **Firebase Cloud Messaging (FCM)** - For push notifications to Android and iOS devices
- **Twilio** - For phone call notifications
- **Amazon SES** - For email notifications
- **Stripe** - For payment processing
Your use of these features is subject to the respective third-party terms and privacy policies. For more
details, see our [privacy policy](privacy.md).
## Links to other websites
Our Service may contain links to third-party websites or services that are not owned or controlled by us.
We have no control over, and assume no responsibility for, the content, privacy policies, or practices of
any third-party websites or services. You acknowledge and agree that we shall not be responsible or liable,
directly or indirectly, for any damage or loss caused by or in connection with the use of any such content,
goods, or services available through any such websites or services.
## Termination
We may terminate or suspend your account immediately, without prior notice or liability, for any reason
whatsoever, including without limitation if you breach these Terms.
Upon termination, your right to use the Service will immediately cease. If you wish to terminate your account,
you may do so through your account settings or by simply discontinuing use of the Service.
Termination of your account will result in the deletion of your account data in accordance with our
[privacy policy](privacy.md).
We may retain certain data as required to comply with legal obligations, resolve disputes, and enforce our
agreements, as described in our privacy policy.
## Limitation of liability
In no event shall ntfy LLC, nor its owner, employees, partners, agents, suppliers, or affiliates, be liable
for any indirect, incidental, special, consequential, or punitive damages, including without limitation:
- Loss of profits, data, use, goodwill, or other intangible losses
- Damages resulting from your access to, use of, or inability to access or use the Service
- Damages resulting from any conduct or content of any third party on the Service
- Damages resulting from any content obtained from the Service
- Damages resulting from unauthorized access, use, or alteration of your transmissions or content
This limitation applies whether based on warranty, contract, tort (including negligence), or any other legal
theory, whether or not we have been informed of the possibility of such damage, and even if a remedy set
forth herein is found to have failed of its essential purpose.
## Indemnification
You agree to defend, indemnify, and hold harmless ntfy LLC and its owner, employees, partners, agents, suppliers,
and affiliates from and against any claims, damages, obligations, losses, liabilities, costs, or debt, and
expenses (including but not limited to attorney's fees) arising from:
- Your use of and access to the Service
- Your violation of any term of these Terms
- Your violation of any applicable law or regulation
- Your content, including any claim that your content infringes or misappropriates the rights of any third party
## Disclaimer
Your use of the Service is at your sole risk. The Service is provided on an "AS IS" and "AS AVAILABLE" basis,
without warranties of any kind, whether express or implied, including but not limited to implied warranties of
merchantability, fitness for a particular purpose, non-infringement, or course of performance.
ntfy LLC does not warrant that:
- The Service will function uninterrupted, secure, or available at any particular time or location
- Any errors or defects will be corrected
- The Service is free of viruses or other harmful components
- The results of using the Service will meet your requirements
- Messages will be delivered successfully or in a timely manner
If your use case requires guaranteed message delivery, high availability, or handling of sensitive data, we
strongly recommend [self-hosting your own ntfy server](install.md) where you have full control over the
infrastructure and data.
## Governing law
These Terms shall be governed and construed in accordance with the laws of the State of Connecticut, United States,
without regard to its conflict of law provisions.
Any legal action or proceeding arising under these Terms shall be brought exclusively in the federal or state
courts located in Connecticut, and the parties hereby consent to personal jurisdiction and venue therein.
Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights.
If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions
of these Terms will remain in effect.
These Terms constitute the entire agreement between us regarding our Service and supersede any prior agreements
we might have had regarding the Service.
## Changes to these Terms
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is
material, we will try to provide at least 30 days' notice prior to any new terms taking effect.
What constitutes a material change will be determined at our sole discretion. Changes will be posted on this
page with an updated "Last updated" date. You may also review all changes in the
[Git history](https://github.com/binwiederhier/ntfy/commits/main/docs/terms.md).
By continuing to access or use our Service after those revisions become effective, you agree to be bound by
the revised Terms. If you do not agree to the new Terms, please stop using the Service.
## Contact
If you have any questions about these Terms, please see our [contact page](contact.md) or email us at
[support@mail.ntfy.sh](mailto:support@mail.ntfy.sh).

View File

@@ -129,3 +129,15 @@ keyboard.
## iOS app
Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.
## Other
### "Reconnecting..." / Late notifications on mobile (self-hosted)
If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration:
* If ntfy is behind a reverse proxy (such as Nginx):
* Make sure `behind-proxy` is enabled in ntfy's config.
* Make sure WebSockets are enabled in the reverse proxy config.
* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through.
* In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush.

58
go.mod
View File

@@ -1,16 +1,14 @@
module heckel.io/ntfy/v2
go 1.24.0
toolchain go1.24.5
go 1.24.6
require (
cloud.google.com/go/firestore v1.20.0 // indirect
cloud.google.com/go/storage v1.59.0 // indirect
cloud.google.com/go/firestore v1.21.0 // indirect
cloud.google.com/go/storage v1.59.2 // 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.12
github.com/gabriel-vasile/mimetype v1.4.13
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.33
github.com/olebedev/when v1.1.0
@@ -21,7 +19,7 @@ require (
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.259.0
google.golang.org/api v0.265.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -30,32 +28,34 @@ 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.18.0
firebase.google.com/go/v4 v4.19.0
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.8.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.40.0
golang.org/x/text v0.33.0
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth v0.18.1 // 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
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.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/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-20251210132809-ee656c7534f5 // 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
@@ -65,13 +65,16 @@ require (
github.com/go-logr/logr v1.4.3 // 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.0 // 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.9 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.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/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
@@ -83,21 +86,20 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // 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
golang.org/x/sys v0.40.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect

109
go.sum
View File

@@ -2,14 +2,14 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/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.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
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/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.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
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=
@@ -18,24 +18,24 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7
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.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8=
cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
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.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
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=
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.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
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/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=
@@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -68,8 +68,8 @@ github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg
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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -81,8 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -96,14 +96,22 @@ 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.9 h1:TOpi/QG8iDcZlkQlGlFUti/ZtyLkliXvHDcyUIMuFrU=
github.com/googleapis/enterprise-certificate-proxy v0.3.9/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
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/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=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
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/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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -144,6 +152,7 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
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/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=
@@ -156,24 +165,24 @@ 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.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
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.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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.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=
@@ -263,16 +272,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
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-20260112192933-99fd39fd28a9 h1:wFALHMUiWKkK/x6rSxm79KpSnUyh7ks2E+mel670Dc4=
google.golang.org/genproto v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4=
google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9 h1:4DKBrmaqeptdEzp21EfrOEh8LE7PJ5ywH6wydSbOfGY=
google.golang.org/genproto/googleapis/api v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9 h1:IY6/YYRrFUk0JPp0xOVctvFIVuRnjccihY5kxf5g0TE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260112192933-99fd39fd28a9/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
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/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

19
main.go
View File

@@ -2,12 +2,14 @@ package main
import (
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
"os"
"runtime"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/cmd"
)
// These variables are set during build time using -ldflags
var (
version = "dev"
commit = "unknown"
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
ntfy %s (%s), runtime %s, built at %s
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
`, version, commit[:7], runtime.Version(), date)
`, version, maybeShortCommit(commit), runtime.Version(), date)
app := cmd.New()
app.Version = version
app.Metadata = map[string]any{
cmd.MetadataKeyDate: date,
cmd.MetadataKeyCommit: commit,
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
func maybeShortCommit(commit string) string {
if len(commit) > 7 {
return commit[:7]
}
return commit
}

606
message/cache.go Normal file
View File

@@ -0,0 +1,606 @@
package message
import (
"database/sql"
"encoding/json"
"errors"
"net/netip"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
const (
tagMessageCache = "message_cache"
)
var errNoRows = errors.New("no rows found")
// queries holds the database-specific SQL queries
type queries struct {
insertMessage string
deleteMessage string
selectScheduledMessageIDsBySeqID string
deleteScheduledBySequenceID string
updateMessagesForTopicExpiry string
selectMessagesByID string
selectMessagesSinceTime string
selectMessagesSinceTimeScheduled string
selectMessagesSinceID string
selectMessagesSinceIDScheduled string
selectMessagesLatest string
selectMessagesDue string
selectMessagesExpired string
updateMessagePublished string
selectMessagesCount string
selectTopics string
updateAttachmentDeleted string
selectAttachmentsExpired string
selectAttachmentsSizeBySender string
selectAttachmentsSizeByUserID string
selectStats string
updateStats string
updateMessageTime string
}
// Cache stores published messages
type Cache struct {
db *sql.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 *sql.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 := strings.Join(m.Tags, ",")
var attachmentName, attachmentType, attachmentURL string
var attachmentSize, attachmentExpires int64
var attachmentDeleted bool
if m.Attachment != nil {
attachmentName = m.Attachment.Name
attachmentType = m.Attachment.Type
attachmentSize = m.Attachment.Size
attachmentExpires = m.Attachment.Expires
attachmentURL = m.Attachment.URL
}
var actionsStr string
if len(m.Actions) > 0 {
actionsBytes, err := json.Marshal(m.Actions)
if err != nil {
return err
}
actionsStr = string(actionsBytes)
}
var sender string
if m.Sender.IsValid() {
sender = m.Sender.String()
}
_, err := stmt.Exec(
m.ID,
m.SequenceID,
m.Time,
m.Event,
m.Expires,
m.Topic,
m.Message,
m.Title,
m.Priority,
tags,
m.Click,
m.Icon,
actionsStr,
attachmentName,
attachmentType,
attachmentSize,
attachmentExpires,
attachmentURL,
attachmentDeleted, // Always zero
sender,
m.User,
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 messages for a topic since the given marker, optionally including scheduled messages
func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
if since.IsNone() {
return make([]*model.Message, 0), nil
} else if since.IsLatest() {
return c.messagesLatest(topic)
} else if since.IsID() {
return c.messagesSinceID(topic, since, scheduled)
}
return c.messagesSinceTime(topic, since, scheduled)
}
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
var rows *sql.Rows
var err error
if scheduled {
rows, err = c.db.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())
} else {
rows, err = c.db.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
}
if err != nil {
return nil, err
}
return readMessages(rows)
}
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
var rows *sql.Rows
var err error
if scheduled {
rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID())
} else {
rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, since.ID())
}
if err != nil {
return nil, err
}
return readMessages(rows)
}
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
rows, err := c.db.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)
}
// MessagesExpired returns a list of IDs for messages that have expired (should be deleted)
func (c *Cache) MessagesExpired() ([]string, error) {
rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ids, nil
}
// 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.Query(c.queries.selectMessagesByID, id)
if err != nil {
return nil, err
}
if !rows.Next() {
return nil, model.ErrMessageNotFound
}
defer rows.Close()
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
func (c *Cache) MessagesCount() (int, error) {
rows, err := c.db.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.Query(c.queries.selectTopics)
if err != nil {
return nil, err
}
defer rows.Close()
topics := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
topics = append(topics, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return topics, nil
}
// DeleteMessages deletes the messages with the given IDs
func (c *Cache) DeleteMessages(ids ...string) error {
c.maybeLock()
defer c.maybeUnlock()
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil {
return err
}
}
return tx.Commit()
}
// 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()
tx, err := c.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// First, get the message IDs of scheduled messages to be deleted
rows, err := tx.Query(c.queries.selectScheduledMessageIDsBySeqID, topic, sequenceID)
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
rows.Close() // Close rows before executing delete in same transaction
// Then delete the messages
if _, err := tx.Exec(c.queries.deleteScheduledBySequenceID, topic, sequenceID); err != nil {
return nil, err
}
if err := tx.Commit(); 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()
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, t := range topics {
if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil {
return err
}
}
return tx.Commit()
}
// AttachmentsExpired returns message IDs with expired attachments that have not been deleted
func (c *Cache) AttachmentsExpired() ([]string, error) {
rows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
ids := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ids, nil
}
// MarkAttachmentsDeleted marks the attachments for the given message IDs as deleted
func (c *Cache) MarkAttachmentsDeleted(ids ...string) error {
c.maybeLock()
defer c.maybeUnlock()
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil {
return err
}
}
return tx.Commit()
}
// 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.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.Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())
if err != nil {
return 0, err
}
return c.readAttachmentBytesUsed(rows)
}
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.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")
}
}
}
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
}

110
message/cache_postgres.go Normal file
View File

@@ -0,0 +1,110 @@
package message
import (
"database/sql"
"time"
)
// 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)
`
postgresDeleteMessageQuery = `DELETE FROM message WHERE mid = $1`
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, id
`
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, id
`
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, id
`
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, id
`
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
`
postgresSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE`
postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message`
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
postgresUpdateAttachmentDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1`
postgresSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE`
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`
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 pgQueries = queries{
insertMessage: postgresInsertMessageQuery,
deleteMessage: postgresDeleteMessageQuery,
selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery,
deleteScheduledBySequenceID: postgresDeleteScheduledBySequenceIDQuery,
updateMessagesForTopicExpiry: postgresUpdateMessagesForTopicExpiryQuery,
selectMessagesByID: postgresSelectMessagesByIDQuery,
selectMessagesSinceTime: postgresSelectMessagesSinceTimeQuery,
selectMessagesSinceTimeScheduled: postgresSelectMessagesSinceTimeIncludeScheduledQuery,
selectMessagesSinceID: postgresSelectMessagesSinceIDQuery,
selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery,
selectMessagesLatest: postgresSelectMessagesLatestQuery,
selectMessagesDue: postgresSelectMessagesDueQuery,
selectMessagesExpired: postgresSelectMessagesExpiredQuery,
updateMessagePublished: postgresUpdateMessagePublishedQuery,
selectMessagesCount: postgresSelectMessagesCountQuery,
selectTopics: postgresSelectTopicsQuery,
updateAttachmentDeleted: postgresUpdateAttachmentDeletedQuery,
selectAttachmentsExpired: postgresSelectAttachmentsExpiredQuery,
selectAttachmentsSizeBySender: postgresSelectAttachmentsSizeBySenderQuery,
selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery,
selectStats: postgresSelectStatsQuery,
updateStats: postgresUpdateStatsQuery,
updateMessageTime: postgresUpdateMessageTimeQuery,
}
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
if err := setupPostgres(db); err != nil {
return nil, err
}
return newCache(db, pgQueries, nil, batchSize, batchTimeout, false), nil
}

View File

@@ -0,0 +1,88 @@
package message
import (
"database/sql"
"fmt"
)
// Initial PostgreSQL schema
const (
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_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);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
`
)
// PostgreSQL schema management queries
const (
pgCurrentSchemaVersion = 14
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`
)
func setupPostgres(db *sql.DB) error {
var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgresDB(db)
}
if schemaVersion > pgCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion)
}
return nil
}
func setupNewPostgresDB(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil {
return err
}
return tx.Commit()
}

142
message/cache_sqlite.go Normal file
View File

@@ -0,0 +1,142 @@
package message
import (
"database/sql"
"fmt"
"path/filepath"
"sync"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
sqliteDeleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
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, id
`
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, id
`
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, id
`
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, id
`
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
`
sqliteSelectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
sqliteUpdateAttachmentDeletedQuery = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
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 >= ?`
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,
deleteMessage: sqliteDeleteMessageQuery,
selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery,
deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery,
updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery,
selectMessagesByID: sqliteSelectMessagesByIDQuery,
selectMessagesSinceTime: sqliteSelectMessagesSinceTimeQuery,
selectMessagesSinceTimeScheduled: sqliteSelectMessagesSinceTimeIncludeScheduledQuery,
selectMessagesSinceID: sqliteSelectMessagesSinceIDQuery,
selectMessagesSinceIDScheduled: sqliteSelectMessagesSinceIDIncludeScheduledQuery,
selectMessagesLatest: sqliteSelectMessagesLatestQuery,
selectMessagesDue: sqliteSelectMessagesDueQuery,
selectMessagesExpired: sqliteSelectMessagesExpiredQuery,
updateMessagePublished: sqliteUpdateMessagePublishedQuery,
selectMessagesCount: sqliteSelectMessagesCountQuery,
selectTopics: sqliteSelectTopicsQuery,
updateAttachmentDeleted: sqliteUpdateAttachmentDeletedQuery,
selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery,
selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery,
selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery,
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)
}
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupSQLite(db, startupQueries, cacheDuration); err != nil {
return nil, err
}
return newCache(db, 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))
}

View File

@@ -0,0 +1,466 @@
package message
import (
"database/sql"
"fmt"
"time"
"heckel.io/ntfy/v2/log"
)
// Initial SQLite schema
const (
sqliteCreateTablesQuery = `
BEGIN;
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);
COMMIT;
`
)
// Schema version management for SQLite
const (
sqliteCurrentSchemaVersion = 14
sqliteCreateSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
// Schema migrations for SQLite
const (
// 0 -> 1
sqliteMigrate0To1AlterMessagesTableQuery = `
BEGIN;
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
COMMIT;
`
// 1 -> 2
sqliteMigrate1To2AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
`
// 2 -> 3
sqliteMigrate2To3AlterMessagesTableQuery = `
BEGIN;
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('');
COMMIT;
`
// 3 -> 4
sqliteMigrate3To4AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
`
// 4 -> 5
sqliteMigrate4To5AlterMessagesTableQuery = `
BEGIN;
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;
COMMIT;
`
// 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 (
sqliteMigrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
0: sqliteMigrateFrom0,
1: sqliteMigrateFrom1,
2: sqliteMigrateFrom2,
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
7: sqliteMigrateFrom7,
8: sqliteMigrateFrom8,
9: sqliteMigrateFrom9,
10: sqliteMigrateFrom10,
11: sqliteMigrateFrom11,
12: sqliteMigrateFrom12,
13: sqliteMigrateFrom13,
}
)
func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
return err
}
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(sqliteSelectMessagesCountQuery)
if err != nil {
return setupNewSQLite(db)
}
rowsMC.Close()
// If 'messages' table exists, check 'schemaVersion' table
schemaVersion := 0
rowsSV, err := db.Query(sqliteSelectSchemaVersionQuery)
if err == nil {
defer rowsSV.Close()
if !rowsSV.Next() {
return fmt.Errorf("cannot determine schema version: cache file may be corrupt")
}
if err := rowsSV.Scan(&schemaVersion); err != nil {
return err
}
rowsSV.Close()
}
// Do migrations
if schemaVersion == sqliteCurrentSchemaVersion {
return nil
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
fn, ok := sqliteMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db, cacheDuration); err != nil {
return err
}
}
return nil
}
func setupNewSQLite(db *sql.DB) error {
if _, err := db.Exec(sqliteCreateTablesQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
}
return nil
}
func sqliteMigrateFrom0(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
if _, err := db.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom1(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
if _, err := db.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom2(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
if _, err := db.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom3(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
if _, err := db.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom4(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
if _, err := db.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom5(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
if _, err := db.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom6(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
if _, err := db.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom7(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
if _, err := db.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom8(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
if _, err := db.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil {
return err
}
return nil
}
func sqliteMigrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil {
return err
}
return tx.Commit()
}
func sqliteMigrateFrom10(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil {
return err
}
return tx.Commit()
}
func sqliteMigrateFrom11(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil {
return err
}
return tx.Commit()
}
func sqliteMigrateFrom12(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil {
return err
}
return tx.Commit()
}
func sqliteMigrateFrom13(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil {
return err
}
return tx.Commit()
}

View File

@@ -0,0 +1,292 @@
package message_test
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func TestSqliteStore_Migration_From0(t *testing.T) {
filename := newSqliteTestStoreFile(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 store to trigger migration
s := newSqliteTestStoreFromFile(t, filename, "")
checkSqliteSchemaVersion(t, filename)
messages, err := s.Messages("mytopic", model.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 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)
// 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, 14, 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, 14, schemaVersion)
require.Nil(t, rows.Close())
}

829
message/cache_test.go Normal file
View File

@@ -0,0 +1,829 @@
package message_test
import (
"net/netip"
"path/filepath"
"sort"
"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)
expiredMessageIDs, err := s.MessagesExpired()
require.Nil(t, err)
require.Nil(t, s.DeleteMessages(expiredMessageIDs...))
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))
ids, err := s.AttachmentsExpired()
require.Nil(t, err)
require.Equal(t, 1, len(ids))
require.Equal(t, "m4", ids[0])
})
}
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)
expiredIDs, err := s.MessagesExpired()
require.Nil(t, err)
require.Equal(t, 2, len(expiredIDs))
sort.Strings(expiredIDs)
expectedIDs := []string{m1.ID, m2.ID}
sort.Strings(expectedIDs)
require.Equal(t, expectedIDs, expiredIDs)
// 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 show as expired attachments needing cleanup
ids, err := s.AttachmentsExpired()
require.Nil(t, err)
require.Equal(t, 2, len(ids))
// Mark msg1's attachment as deleted (file cleaned up)
require.Nil(t, s.MarkAttachmentsDeleted("msg1"))
// Now only msg2 should show as needing cleanup
ids, err = s.AttachmentsExpired()
require.Nil(t, err)
require.Equal(t, 1, len(ids))
require.Equal(t, "msg2", ids[0])
// Mark msg2 too
require.Nil(t, s.MarkAttachmentsDeleted("msg2"))
// No more expired attachments to clean up
ids, err = s.AttachmentsExpired()
require.Nil(t, err)
require.Equal(t, 0, len(ids))
// 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)
})
}

View File

@@ -101,6 +101,7 @@ nav:
- "Development": develop.md
- "Contributing": contributing.md
- "Privacy policy": privacy.md
- "Terms of Service": terms.md
- "Contact": contact.md

212
model/model.go Normal file
View File

@@ -0,0 +1,212 @@
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
}
// 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
}
// 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
}
// NewAction creates a new action with initialized maps
func NewAction() *Action {
return &Action{
Headers: make(map[string]string),
Extras: make(map[string]string),
}
}
// NewMessage creates a new message with the current timestamp
func NewMessage(event, topic, msg string) *Message {
return &Message{
ID: util.RandomString(MessageIDLength),
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
}
// ValidMessageID returns true if the given string is a valid message ID
func ValidMessageID(s string) bool {
return util.ValidRandomString(s, MessageIDLength)
}
// 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"}
)

View File

@@ -4,10 +4,12 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/v2/util"
"regexp"
"strings"
"unicode/utf8"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
const (
@@ -20,12 +22,14 @@ const (
actionView = "view"
actionBroadcast = "broadcast"
actionHTTP = "http"
actionCopy = "copy"
)
var (
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
actionsWithURL = []string{actionView, actionHTTP}
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
)
type actionParser struct {
@@ -36,7 +40,7 @@ type actionParser struct {
// parseActions 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 parseActions(s string) (actions []*model.Action, err error) {
// Parse JSON or simple format
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[") {
@@ -61,11 +65,13 @@ func parseActions(s string) (actions []*action, err error) {
}
for _, action := range actions {
if !util.Contains(actionsAll, action.Action) {
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
} else if action.Label == "" {
return nil, fmt.Errorf("parameter 'label' is required")
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
}
@@ -75,8 +81,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)
}
@@ -102,7 +108,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")
}
@@ -114,8 +120,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 {
@@ -129,8 +135,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()
@@ -150,7 +156,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"
@@ -158,6 +164,8 @@ func populateAction(newAction *action, section int, key, value string) error {
key = "label"
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
key = "url"
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
key = "value"
}
// Validate
@@ -188,6 +196,8 @@ func populateAction(newAction *action, section int, key, value string) error {
newAction.Method = value
case "body":
newAction.Body = value
case "value":
newAction.Value = value
case "intent":
newAction.Intent = value
default:

View File

@@ -1,8 +1,9 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseActions(t *testing.T) {
@@ -132,6 +133,44 @@ func TestParseActions(t *testing.T) {
require.Equal(t, `https://x.org`, actions[1].URL)
require.Equal(t, true, actions[1].Clear)
// Copy action (simple format)
actions, err = parseActions("copy, Copy code, 1234")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy code", actions[0].Label)
require.Equal(t, "1234", actions[0].Value)
// Copy action (JSON)
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy OTP", actions[0].Label)
require.Equal(t, "567890", actions[0].Value)
// Copy action with clear
actions, err = parseActions("copy, Copy code, 1234, clear=true")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy code", actions[0].Label)
require.Equal(t, "1234", actions[0].Value)
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")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "copy", actions[0].Action)
require.Equal(t, "Copy token", actions[0].Label)
require.Equal(t, "abc-123-def", actions[0].Value)
require.True(t, actions[0].Clear)
// Copy action without value (error)
_, err = parseActions("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`)
require.EqualError(t, err, "unexpected character 'x' at position 22")
@@ -146,7 +185,7 @@ func TestParseActions(t *testing.T) {
require.EqualError(t, err, "term 'what is this anyway' unknown")
_, err = parseActions(`fdsfdsf`)
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
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 "`)
require.EqualError(t, err, "key 'aaa' unknown")
@@ -173,7 +212,7 @@ func TestParseActions(t *testing.T) {
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
_, err = parseActions(`[ { "some": "object" } ]`)
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
_, err = parseActions("\x00\x01\xFFx\xFE")
require.EqualError(t, err, "invalid utf-8 string")

View File

@@ -1,8 +1,13 @@
package server
import (
"crypto/sha256"
"encoding/json"
"fmt"
"io/fs"
"net/netip"
"reflect"
"text/template"
"time"
"heckel.io/ntfy/v2/user"
@@ -11,8 +16,6 @@ import (
// Defines default config settings (excluding limits, see below)
const (
DefaultListenHTTP = ":80"
DefaultConfigFile = "/etc/ntfy/server.yml"
DefaultTemplateDir = "/etc/ntfy/templates"
DefaultCacheDuration = 12 * time.Hour
DefaultCacheBatchTimeout = time.Duration(0)
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
@@ -26,6 +29,12 @@ const (
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
)
// Platform-specific default paths (set in config_unix.go or config_windows.go)
var (
DefaultConfigFile string
DefaultTemplateDir string
)
// Defines default Web Push settings
const (
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
@@ -86,6 +95,7 @@ type Config struct {
ListenUnixMode fs.FileMode
KeyFile string
CertFile string
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
@@ -128,6 +138,7 @@ type Config struct {
TwilioCallsBaseURL string
TwilioVerifyBaseURL string
TwilioVerifyService string
TwilioCallFormat *template.Template
MetricsEnable bool
MetricsListenHTTP string
ProfileListenHTTP string
@@ -173,7 +184,9 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
Version string // injected by App
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
}
// NewConfig instantiates a default new server config
@@ -187,6 +200,7 @@ func NewConfig() *Config {
ListenUnixMode: 0,
KeyFile: "",
CertFile: "",
DatabaseURL: "",
FirebaseKeyFile: "",
CacheFile: "",
CacheDuration: DefaultCacheDuration,
@@ -226,6 +240,7 @@ func NewConfig() *Config {
TwilioPhoneNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "",
TwilioCallFormat: nil,
MessageSizeLimit: DefaultMessageSizeLimit,
MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax,
@@ -259,12 +274,32 @@ func NewConfig() *Config {
EnableReservations: false,
RequireLogin: false,
AccessControlAllowOrigin: "*",
Version: "",
WebPushPrivateKey: "",
WebPushPublicKey: "",
WebPushFile: "",
WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BuildVersion: "",
BuildDate: "",
BuildCommit: "",
}
}
// Hash computes an SHA-256 hash of the configuration. This is used to detect
// configuration changes for the web app version check feature. It uses reflection
// to include all JSON-serializable fields automatically.
func (c *Config) Hash() string {
v := reflect.ValueOf(*c)
t := v.Type()
var result string
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldName := t.Field(i).Name
// 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))
}
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
}

8
server/config_unix.go Normal file
View File

@@ -0,0 +1,8 @@
//go:build !windows
package server
func init() {
DefaultConfigFile = "/etc/ntfy/server.yml"
DefaultTemplateDir = "/etc/ntfy/templates"
}

17
server/config_windows.go Normal file
View File

@@ -0,0 +1,17 @@
//go:build windows
package server
import (
"os"
"path/filepath"
)
func init() {
programData := os.Getenv("ProgramData")
if programData == "" {
programData = `C:\ProgramData`
}
DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml")
DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates")
}

View File

@@ -78,6 +78,21 @@ func (e errHTTP) clone() errHTTP {
}
}
// errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket
// upgrade completed (i.e., the connection was hijacked). This is used to avoid calling
// WriteHeader on hijacked connections, which causes log spam.
type errWebSocketPostUpgrade struct {
err error
}
func (e *errWebSocketPostUpgrade) Error() string {
return e.err.Error()
}
func (e *errWebSocketPostUpgrade) Unwrap() error {
return e.err
}
var (
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
"io"
"os"
@@ -13,7 +14,7 @@ import (
)
var (
fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength))
fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength))
errInvalidFileID = errors.New("invalid file ID")
errFileExists = errors.New("file exists")
)

View File

@@ -1,14 +1,17 @@
package server
import (
"errors"
"fmt"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"net/http"
"strings"
"unicode/utf8"
"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
@@ -53,12 +56,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)
}
@@ -83,7 +86,8 @@ func httpContext(r *http.Request) log.Context {
}
func websocketErrorContext(err error) log.Context {
if c, ok := err.(*websocket.CloseError); ok {
var c *websocket.CloseError
if errors.As(err, &c) {
return log.Context{
"error": c.Error(),
"error_code": c.Code,

File diff suppressed because it is too large Load Diff

View File

@@ -1,752 +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 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
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"embed"
"encoding/base64"
"encoding/json"
@@ -32,16 +33,21 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
"heckel.io/ntfy/v2/webpush"
)
// Server is the main server, providing the UI and API for ntfy
type Server struct {
config *Config
db *sql.DB // Shared PostgreSQL connection pool, nil when using SQLite
httpServer *http.Server
httpsServer *http.Server
httpMetricsServer *http.Server
@@ -56,8 +62,8 @@ type Server struct {
messages int64 // Total number of messages (persisted if messageCache enabled)
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
userManager *user.Manager // Might be nil!
messageCache *messageCache // Database that stores the messages
webPush *webPushStore // Database that stores web push subscriptions
messageCache *message.Cache // Database that stores the messages
webPush *webpush.Store // Database that stores web push subscriptions
fileCache *fileCache // File system based cache that stores attachments
stripe stripeAPI // Stripe API, can be replaced with a mock
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
@@ -90,6 +96,8 @@ var (
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
apiHealthPath = "/v1/health"
apiVersionPath = "/v1/version"
apiConfigPath = "/v1/config"
apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers"
@@ -170,21 +178,38 @@ func New(conf *Config) (*Server, error) {
if payments.Available && conf.StripeSecretKey != "" {
stripe = newStripeAPI()
}
messageCache, err := createMessageCache(conf)
if err != nil {
return nil, err
}
var webPush *webPushStore
if conf.WebPushPublicKey != "" {
webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries)
// OpenPostgres shared PostgreSQL connection pool if configured
var pool *sql.DB
if conf.DatabaseURL != "" {
var err error
pool, err = db.OpenPostgres(conf.DatabaseURL)
if err != nil {
return nil, err
}
}
topics, err := messageCache.Topics()
messageCache, err := createMessageCache(conf, pool)
if err != nil {
return nil, err
}
var wp *webpush.Store
if conf.WebPushPublicKey != "" {
if pool != nil {
wp, err = webpush.NewPostgresStore(pool)
} else {
wp, err = webpush.NewSQLiteStore(conf.WebPushFile, conf.WebPushStartupQueries)
}
if err != nil {
return nil, err
}
}
topicIDs, err := messageCache.Topics()
if err != nil {
return nil, err
}
topics := make(map[string]*topic, len(topicIDs))
for _, id := range topicIDs {
topics[id] = newTopic(id)
}
messages, err := messageCache.Stats()
if err != nil {
return nil, err
@@ -197,9 +222,10 @@ func New(conf *Config) (*Server, error) {
}
}
var userManager *user.Manager
if conf.AuthFile != "" {
if conf.AuthFile != "" || pool != nil {
authConfig := &user.Config{
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
@@ -209,7 +235,11 @@ func New(conf *Config) (*Server, error) {
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
}
userManager, err = user.NewManager(authConfig)
if pool != nil {
userManager, err = user.NewPostgresManager(pool, authConfig)
} else {
userManager, err = user.NewSQLiteManager(conf.AuthFile, conf.AuthStartupQueries, authConfig)
}
if err != nil {
return nil, err
}
@@ -230,8 +260,9 @@ func New(conf *Config) (*Server, error) {
}
s := &Server{
config: conf,
db: pool,
messageCache: messageCache,
webPush: webPush,
webPush: wp,
fileCache: fileCache,
firebaseClient: firebaseClient,
smtpSender: mailer,
@@ -246,13 +277,15 @@ func New(conf *Config) (*Server, error) {
return s, nil
}
func createMessageCache(conf *Config) (*messageCache, error) {
func createMessageCache(conf *Config, pool *sql.DB) (*message.Cache, error) {
if conf.CacheDuration == 0 {
return newNopCache()
return message.NewNopStore()
} else if pool != nil {
return message.NewPostgresStore(pool, conf.CacheBatchSize, conf.CacheBatchTimeout)
} else if conf.CacheFile != "" {
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
return message.NewSQLiteStore(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
}
return newMemCache()
return message.NewMemStore()
}
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
@@ -277,9 +310,9 @@ func (s *Server) Run() error {
if s.config.ProfileListenHTTP != "" {
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
}
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String())
if log.IsFile() {
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion)
fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
}
mux := http.NewServeMux()
@@ -387,6 +420,9 @@ func (s *Server) closeDatabases() {
if s.webPush != nil {
s.webPush.Close()
}
if s.db != nil {
s.db.Close()
}
}
// handle is the main entry point for all HTTP requests
@@ -433,8 +469,14 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
} else {
ev.Info("WebSocket error: %s", err.Error())
}
w.WriteHeader(httpErr.HTTPCode)
return // Do not attempt to write any body to upgraded connection
// Write error response only if the connection was not hijacked yet. Bytes written to hijacked
// connections are WebSocket frames, not HTTP, and will cause "http: response.WriteHeader on hijacked
// connection" log spam.
var postUpgradeErr *errWebSocketPostUpgrade
if !errors.As(err, &postUpgradeErr) {
w.WriteHeader(httpErr.HTTPCode)
}
return
}
if isNormalError {
ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
@@ -460,6 +502,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
return s.handleHealth(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiVersionPath {
return s.ensureAdmin(s.handleVersion)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
return s.handleConfig(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
@@ -600,8 +646,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
return s.writeJSON(w, response)
}
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
w.Header().Set("Cache-Control", "no-cache")
return s.writeJSON(w, s.configResponse())
}
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &apiConfigResponse{
b, err := json.MarshalIndent(s.configResponse(), "", " ")
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/javascript")
w.Header().Set("Cache-Control", "no-cache")
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
return err
}
func (s *Server) configResponse() *apiConfigResponse {
return &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot,
EnableLogin: s.config.EnableLogin,
@@ -615,21 +677,14 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
BillingContact: s.config.BillingContact,
WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
ConfigHash: s.config.Hash(),
}
b, err := json.MarshalIndent(response, "", " ")
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/javascript")
w.Header().Set("Cache-Control", "no-cache")
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
return err
}
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &webManifestResponse{
Name: "ntfy web",
Name: "ntfy",
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
ShortName: "ntfy",
Scope: "/",
@@ -710,11 +765,11 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
// - avoid abuse (e.g. 1 uploader, 1k downloaders)
// - and also uses the higher bandwidth limits of a paying user
m, err := s.messageCache.Message(messageID)
if errors.Is(err, errMessageNotFound) {
if errors.Is(err, model.ErrMessageNotFound) {
if s.config.CacheBatchTimeout > 0 {
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
// and messages are persisted asynchronously, retry fetching from the database
m, err = util.Retry(func() (*message, error) {
m, err = util.Retry(func() (*model.Message, error) {
return s.messageCache.Message(messageID)
}, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond)
}
@@ -760,7 +815,7 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
return writeMatrixDiscoveryResponse(w)
}
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) {
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {
start := time.Now()
t, err := fromContext[*topic](r, contextTopic)
if err != nil {
@@ -774,8 +829,8 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if err != nil {
return nil, err
}
m := newDefaultMessage(t.ID, "")
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m)
m := model.NewDefaultMessage(t.ID, "")
cache, firebase, email, call, template, unifiedpush, priorityStr, e := s.parsePublishParams(r, m)
if e != nil {
return nil, e.With(t)
}
@@ -799,14 +854,14 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
}
}
if m.PollID != "" {
m = newPollRequestMessage(t.ID, m.PollID)
m = model.NewPollRequestMessage(t.ID, m.PollID)
}
m.Sender = v.IP()
m.User = v.MaybeUserID()
if cache {
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
}
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush, priorityStr); err != nil {
return nil, err
}
if m.Message == "" {
@@ -851,6 +906,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
if cache {
// Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)
if err != nil {
return nil, err
}
// Delete attachment files for deleted scheduled messages
if s.fileCache != nil && len(deletedIDs) > 0 {
if err := s.fileCache.Remove(deletedIDs...); err != nil {
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
}
}
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
if err := s.messageCache.AddMessage(m); err != nil {
return nil, err
@@ -877,7 +943,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return err
}
minc(metricMessagesPublishedSuccess)
return s.writeJSON(w, m.forJSON())
return s.writeJSON(w, m.ForJSON())
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -906,11 +972,11 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.handleActionMessage(w, r, v, messageDeleteEvent)
return s.handleActionMessage(w, r, v, model.MessageDeleteEvent)
}
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.handleActionMessage(w, r, v, messageClearEvent)
return s.handleActionMessage(w, r, v, model.MessageClearEvent)
}
func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {
@@ -930,7 +996,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
return e.With(t)
}
// Create an action message with the given event type
m := newActionMessage(event, t.ID, sequenceID)
m := model.NewActionMessage(event, t.ID, sequenceID)
m.Sender = v.IP()
m.User = v.MaybeUserID()
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
@@ -946,6 +1012,19 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
if event == model.MessageDeleteEvent {
// Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
if err != nil {
return err
}
// Delete attachment files for deleted scheduled messages
if s.fileCache != nil && len(deletedIDs) > 0 {
if err := s.fileCache.Remove(deletedIDs...); err != nil {
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
}
}
}
// Add to message cache
if err := s.messageCache.AddMessage(m); err != nil {
return err
@@ -954,10 +1033,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
s.mu.Lock()
s.messages++
s.mu.Unlock()
return s.writeJSON(w, m.forJSON())
return s.writeJSON(w, m.ForJSON())
}
func (s *Server) sendToFirebase(v *visitor, m *message) {
func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
if err := s.firebaseClient.Send(v, m); err != nil {
minc(metricFirebasePublishedFailure)
@@ -971,7 +1050,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) {
minc(metricFirebasePublishedSuccess)
}
func (s *Server) sendEmail(v *visitor, m *message, email string) {
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email)
if err := s.smtpSender.Send(v, m, email); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
@@ -981,7 +1060,7 @@ func (s *Server) sendEmail(v *visitor, m *message, email string) {
minc(metricEmailsPublishedSuccess)
}
func (s *Server) forwardPollRequest(v *visitor, m *message) {
func (s *Server) forwardPollRequest(v *visitor, m *model.Message) {
topicURL := fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic)
topicHash := fmt.Sprintf("%x", sha256.Sum256([]byte(topicURL)))
forwardURL := fmt.Sprintf("%s/%s", s.config.UpstreamBaseURL, topicHash)
@@ -991,7 +1070,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
logvm(v, m).Err(err).Warn("Unable to publish poll request")
return
}
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Set("X-Poll-ID", m.ID)
if s.config.UpstreamAccessToken != "" {
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))
@@ -1013,11 +1092,11 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
}
}
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, priorityStr string, err *errHTTP) {
if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
if err != nil {
return false, false, "", "", "", false, err
return false, false, "", "", "", false, "", err
}
m.SequenceID = pathSequenceID
} else {
@@ -1026,7 +1105,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
if sequenceIDRegex.MatchString(sequenceID) {
m.SequenceID = sequenceID
} else {
return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid
return false, false, "", "", "", false, "", errHTTPBadRequestSequenceIDInvalid
}
} else {
m.SequenceID = m.ID
@@ -1040,14 +1119,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
filename := readParam(r, "x-filename", "filename", "file", "f")
attach := readParam(r, "x-attach", "attach", "a")
if attach != "" || filename != "" {
m.Attachment = &attachment{}
m.Attachment = &model.Attachment{}
}
if filename != "" {
m.Attachment.Name = filename
}
if attach != "" {
if !urlRegex.MatchString(attach) {
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
return false, false, "", "", "", false, "", errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
if m.Attachment.Name == "" {
@@ -1065,19 +1144,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
}
if icon != "" {
if !urlRegex.MatchString(icon) {
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
return false, false, "", "", "", false, "", errHTTPBadRequestIconURLInvalid
}
m.Icon = icon
}
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if s.smtpSender == nil && email != "" {
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled
}
call = readParam(r, "x-call", "call")
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled
return false, false, "", "", "", false, "", errHTTPBadRequestPhoneCallsDisabled
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
return false, false, "", "", "", false, "", errHTTPBadRequestPhoneNumberInvalid
}
template = templateMode(readParam(r, "x-template", "template", "tpl"))
messageStr := readParam(r, "x-message", "message", "m")
@@ -1089,29 +1168,33 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
m.Message = messageStr
}
var e error
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if e != nil {
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
priorityStr = readParam(r, "x-priority", "priority", "prio", "p")
if !template.Enabled() {
m.Priority, e = util.ParsePriority(priorityStr)
if e != nil {
return false, false, "", "", "", false, "", errHTTPBadRequestPriorityInvalid
}
priorityStr = "" // Clear since it's already parsed
}
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
if call != "" {
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
return false, false, "", "", "", false, "", errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
return false, false, "", "", "", false, "", errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
return false, false, "", "", "", false, "", errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
}
@@ -1119,7 +1202,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
if actionsStr != "" {
m.Actions, e = parseActions(actionsStr)
if e != nil {
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
return false, false, "", "", "", false, "", errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
}
}
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
@@ -1138,7 +1221,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
cache = false
email = ""
}
return cache, firebase, email, call, template, unifiedpush, nil
return cache, firebase, email, call, template, unifiedpush, priorityStr, nil
}
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
@@ -1157,8 +1240,8 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 7. curl -T file.txt ntfy.sh/mytopic
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error {
if m.Event == pollRequestEvent { // Case 1
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool, priorityStr string) error {
if m.Event == model.PollRequestEvent { // Case 1
return s.handleBodyDiscard(body)
} else if unifiedpush {
return s.handleBodyAsMessageAutoDetect(m, body) // Case 2
@@ -1167,7 +1250,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
} else if template.Enabled() {
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 6
}
@@ -1180,7 +1263,7 @@ func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
return err
}
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
func (s *Server) handleBodyAsMessageAutoDetect(m *model.Message, body *util.PeekedReadCloser) error {
if utf8.Valid(body.PeekedBytes) {
m.Message = string(body.PeekedBytes) // Do not trim
} else {
@@ -1190,7 +1273,7 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead
return nil
}
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
func (s *Server) handleBodyAsTextMessage(m *model.Message, body *util.PeekedReadCloser) error {
if !utf8.Valid(body.PeekedBytes) {
return errHTTPBadRequestMessageNotUTF8.With(m)
}
@@ -1203,7 +1286,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
return nil
}
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error {
func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil {
return err
@@ -1216,7 +1299,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
return err
}
} else {
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
return err
}
}
@@ -1228,7 +1311,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
// The template file must be in the templates directory, or in the configured template directory.
func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody string) error {
func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error {
if !templateNameRegex.MatchString(templateName) {
return errHTTPBadRequestTemplateFileNotFound
}
@@ -1247,33 +1330,51 @@ func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody str
}
var err error
if tpl.Message != nil {
if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil {
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil {
return err
}
}
if tpl.Title != nil {
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil {
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil {
return err
}
}
if tpl.Priority != nil {
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil
}
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message and title parameters.
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error {
// message, title, and priority parameters.
func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error {
var err error
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil {
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil {
return err
}
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil {
return err
}
if priorityStr != "" {
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
if err != nil {
return err
}
if m.Priority, err = util.ParsePriority(renderedPriority); err != nil {
return errHTTPBadRequestPriorityInvalid
}
}
return nil
}
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(tpl string, source string) (string, error) {
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
if templateDisallowedRegex.MatchString(tpl) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
@@ -1288,12 +1389,12 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) {
var buf bytes.Buffer
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.Execute(limitWriter, data); err != nil {
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
}
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
}
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error {
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
return errHTTPBadRequestAttachmentsDisallowed.With(m)
}
@@ -1317,7 +1418,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
}
}
if m.Attachment == nil {
m.Attachment = &attachment{}
m.Attachment = &model.Attachment{}
}
var ext string
m.Attachment.Expires = attachmentExpiry
@@ -1344,9 +1445,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
}
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
encoder := func(msg *model.Message) (string, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
return "", err
}
return buf.String(), nil
@@ -1355,12 +1456,12 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
}
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
encoder := func(msg *model.Message) (string, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
if err := json.NewEncoder(&buf).Encode(msg.ForJSON()); err != nil {
return "", err
}
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
}
return fmt.Sprintf("data: %s\n", buf.String()), nil
@@ -1369,8 +1470,8 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v
}
func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
if msg.Event == messageEvent { // only handle default events
encoder := func(msg *model.Message) (string, error) {
if msg.Event == model.MessageEvent { // only handle default events
return strings.ReplaceAll(msg.Message, "\n", " ") + "\n", nil
}
return "\n", nil // "keepalive" and "open" events just send an empty line
@@ -1394,14 +1495,18 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
return err
}
var wlock sync.Mutex
var closed bool
defer func() {
// Hack: This is the fix for a horrible data race that I have not been able to figure out in quite some time.
// It appears to be happening when the Go HTTP code reads from the socket when closing the request (i.e. AFTER
// this function returns), and causes a data race with the ResponseWriter. Locking wlock here silences the
// data race detector. See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889.
wlock.TryLock()
// This blocks until any in-flight sub() call finishes writing/flushing the response writer,
// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic
// from writing to a response writer that has been cleaned up after the handler returns.
// See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889
// and https://github.com/binwiederhier/ntfy/pull/1598.
wlock.Lock()
closed = true
wlock.Unlock()
}()
sub := func(v *visitor, msg *message) error {
sub := func(v *visitor, msg *model.Message) error {
if !filters.Pass(msg) {
return nil
}
@@ -1411,6 +1516,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
}
wlock.Lock()
defer wlock.Unlock()
if closed {
return nil
}
if _, err := w.Write([]byte(m)); err != nil {
return err
}
@@ -1441,7 +1549,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
topics[i].Unsubscribe(subscriberID) // Order!
}
}()
if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
return err
}
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
@@ -1464,7 +1572,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
for _, t := range topics {
t.Keepalive()
}
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
if err := sub(v, model.NewKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
return err
}
}
@@ -1560,7 +1668,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
}
}
})
sub := func(v *visitor, msg *message) error {
sub := func(v *visitor, msg *model.Message) error {
if !filters.Pass(msg) {
return nil
}
@@ -1590,7 +1698,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
topics[i].Unsubscribe(subscriberID) // Order!
}
}()
if err := sub(v, newOpenMessage(topicsStr)); err != nil { // Send out open message
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
return err
}
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil {
@@ -1601,10 +1709,13 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Trace("WebSocket connection closed")
return nil // Normal closures are not errors; note: "1006 (abnormal closure)" is treated as normal, because people disconnect a lot
}
return err
if err != nil {
return &errWebSocketPostUpgrade{err}
}
return nil
}
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
func parseSubscribeParams(r *http.Request) (poll bool, since model.SinceMarker, scheduled bool, filters *queryFilter, err error) {
poll = readBoolParam(r, false, "x-poll", "poll", "po")
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
since, err = parseSince(r, poll)
@@ -1685,11 +1796,11 @@ func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topi
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
// marker, returning only messages that are newer than the marker.
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
func (s *Server) sendOldMessages(topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error {
if since.IsNone() {
return nil
}
messages := make([]*message, 0)
messages := make([]*model.Message, 0)
for _, t := range topics {
topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled)
if err != nil {
@@ -1712,32 +1823,32 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled b
//
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h),
// "all" for all messages, or "latest" for the most recent message for a topic
func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
func parseSince(r *http.Request, poll bool) (model.SinceMarker, error) {
since := readParam(r, "x-since", "since", "si")
// Easy cases (empty, all, none)
if since == "" {
if poll {
return sinceAllMessages, nil
return model.SinceAllMessages, nil
}
return sinceNoMessages, nil
return model.SinceNoMessages, nil
} else if since == "all" {
return sinceAllMessages, nil
return model.SinceAllMessages, nil
} else if since == "latest" {
return sinceLatestMessage, nil
return model.SinceLatestMessage, nil
} else if since == "none" {
return sinceNoMessages, nil
return model.SinceNoMessages, nil
}
// ID, timestamp, duration
if validMessageID(since) {
return newSinceID(since), nil
if model.ValidMessageID(since) {
return model.NewSinceID(since), nil
} else if s, err := strconv.ParseInt(since, 10, 64); err == nil {
return newSinceTime(s), nil
return model.NewSinceTime(s), nil
} else if d, err := time.ParseDuration(since); err == nil {
return newSinceTime(time.Now().Add(-1 * d).Unix()), nil
return model.NewSinceTime(time.Now().Add(-1 * d).Unix()), nil
}
return sinceNoMessages, errHTTPBadRequestSinceInvalid
return model.SinceNoMessages, errHTTPBadRequestSinceInvalid
}
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
@@ -1893,14 +2004,14 @@ func (s *Server) runFirebaseKeepaliver() {
for {
select {
case <-time.After(s.config.FirebaseKeepaliveInterval):
s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic))
s.sendToFirebase(v, model.NewKeepaliveMessage(firebaseControlTopic))
/*
FIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677)
To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly.
Given that it's not really necessary to poll, turning it off for now should not have any impact.
case <-time.After(s.config.FirebasePollInterval):
s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic))
s.sendToFirebase(v, model.NewKeepaliveMessage(firebasePollTopic))
*/
case <-s.closeChan:
return
@@ -1943,7 +2054,7 @@ func (s *Server) sendDelayedMessages() error {
return nil
}
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {
logvm(v, m).Debug("Sending delayed message")
s.mu.RLock()
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published

View File

@@ -38,8 +38,32 @@
#
# firebase-key-file: <filename>
# 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.
#
# 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.
@@ -77,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".
@@ -160,7 +186,7 @@
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
# messages will additionally be sent out as e-mail using an external SMTP server.
#
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS are supported.
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
#
# - smtp-sender-addr is the hostname:port of the SMTP server
@@ -197,9 +223,10 @@
# - 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`)
# - 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)
# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)
#
# web-push-public-key:
@@ -216,11 +243,13 @@
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml)
#
# twilio-account:
# twilio-auth-token:
# twilio-phone-number:
# twilio-verify-service:
# twilio-call-format:
# Interval in which keepalive messages are sent to the client. This is to prevent
# intermediaries closing the connection for inactivity.
@@ -278,7 +307,7 @@
#
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
# if you exceed the upstream rate limits, or the uptream server requires authentication.
# if you exceed the upstream rate limits, or the upstream server requires authentication.
#
# upstream-base-url:
# upstream-access-token:

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"net/http"
@@ -641,7 +642,7 @@ func (s *Server) publishSyncEvent(v *visitor) error {
if err != nil {
return err
}
m := newDefaultMessage(syncTopic.ID, string(messageBytes))
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
if err := syncTopic.Publish(v, m); err != nil {
return err
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,14 @@ import (
"net/http"
)
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.writeJSON(w, &apiVersionResponse{
Version: s.config.BuildVersion,
Commit: s.config.BuildCommit,
Date: s.config.BuildDate,
})
}
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
users, err := s.userManager.Users()
if err != nil {

View File

@@ -1,6 +1,7 @@
package server
import (
"encoding/json"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
@@ -9,393 +10,452 @@ import (
"time"
)
func TestVersion_Admin(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.BuildVersion = "1.2.3"
c.BuildCommit = "abcdef0"
c.BuildDate = "2026-02-08T00:00:00Z"
s := newTestServer(t, c)
defer s.closeDatabases()
// Create admin and regular user
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Admin can access /v1/version
rr := request(t, s, "GET", "/v1/version", "", map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
var versionResponse apiVersionResponse
require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse))
require.Equal(t, "1.2.3", versionResponse.Version)
require.Equal(t, "abcdef0", versionResponse.Commit)
require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date)
// Non-admin user cannot access /v1/version
rr = request(t, s, "GET", "/v1/version", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Unauthenticated user cannot access /v1/version
rr = request(t, s, "GET", "/v1/version", "", nil)
require.Equal(t, 401, rr.Code)
})
}
func TestUser_AddRemove(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Create user with tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 4, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Nil(t, users[1].Tier)
require.Equal(t, "emma", users[2].Name)
require.Equal(t, user.RoleUser, users[2].Role)
require.Equal(t, "tier1", users[2].Tier.Code)
require.Equal(t, user.Everyone, users[3].Name)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check user was deleted
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "emma", users[1].Name)
require.Equal(t, user.Everyone, users[2].Name)
// Reject invalid user change
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
})
require.Equal(t, 200, rr.Code)
// Create user with tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 4, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Nil(t, users[1].Tier)
require.Equal(t, "emma", users[2].Name)
require.Equal(t, user.RoleUser, users[2].Role)
require.Equal(t, "tier1", users[2].Tier.Code)
require.Equal(t, user.Everyone, users[3].Name)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check user was deleted
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "emma", users[1].Name)
require.Equal(t, user.Everyone, users[2].Name)
// Reject invalid user change
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
}
func TestUser_AddWithPasswordHash(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check that user can login with password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, user.RoleAdmin, users[0].Role)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
}
func TestUser_ChangeUserPassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Change password via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
}
func TestUser_ChangeUserTier(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users again
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
}
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
// Check new tier
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
}
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "not-ben"),
})
require.Equal(t, 200, rr.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
}
func TestUser_DontChangeAdminPassword(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
// Try to change password via API
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
}
func TestUser_AddRemove_Failures(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Cannot create user with invalid username
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
// Cannot create user if user already exists
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
// Cannot create user with invalid tier
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
// Cannot delete user as non-admin
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
}
func TestAccess_AllowReset(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Subscribing not allowed
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
// Grant access
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Now subscribing is allowed
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Reset access
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Subscribing not allowed (again)
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
}
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Grant access fails, because non-admin
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
}
func TestAccess_AllowReset_KillConnection(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin, grant access to "gol*" topics
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
start, timeTaken := time.Now(), atomic.Int64{}
go func() {
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
// Check that user can login with password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
timeTaken.Store(time.Since(start).Milliseconds())
}()
time.Sleep(500 * time.Millisecond)
// Reset access
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Wait for connection to be killed; this will fail if the connection is never killed
waitFor(t, func() bool {
return timeTaken.Load() >= 500
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, user.RoleAdmin, users[0].Role)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
})
}
func TestUser_ChangeUserPassword(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Change password via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
})
}
func TestUser_ChangeUserTier(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users again
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
})
}
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin, tier
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier1",
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "tier2",
}))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Check users
users, err := s.userManager.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, user.RoleUser, users[1].Role)
require.Equal(t, "tier1", users[1].Tier.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Make sure first password fails
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben-two"),
})
require.Equal(t, 200, rr.Code)
// Check new tier
users, err = s.userManager.Users()
require.Nil(t, err)
require.Equal(t, "tier2", users[1].Tier.Code)
})
}
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
// Create user with tier via API
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with first password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "not-ben"),
})
require.Equal(t, 200, rr.Code)
// Change user password and tier via API
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Try to login with second password
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
})
}
func TestUser_DontChangeAdminPassword(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
// Try to change password via API
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 403, rr.Code)
})
}
func TestUser_AddRemove_Failures(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
defer s.closeDatabases()
// Create admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Cannot create user with invalid username
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, rr.Code)
// Cannot create user if user already exists
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
// Cannot create user with invalid tier
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
// Cannot delete user as non-admin
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
// Delete user via API
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
})
}
func TestAccess_AllowReset(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Subscribing not allowed
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
// Grant access
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Now subscribing is allowed
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
// Reset access
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Subscribing not allowed (again)
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 403, rr.Code)
})
}
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Grant access fails, because non-admin
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, rr.Code)
})
}
func TestAccess_AllowReset_KillConnection(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, c)
defer s.closeDatabases()
// User and admin, grant access to "gol*" topics
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
start, timeTaken := time.Now(), atomic.Int64{}
go func() {
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, rr.Code)
timeTaken.Store(time.Since(start).Milliseconds())
}()
time.Sleep(500 * time.Millisecond)
// Reset access
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, rr.Code)
// Wait for connection to be killed; this will fail if the connection is never killed
waitFor(t, func() bool {
return timeTaken.Load() >= 500
})
})
}

View File

@@ -10,6 +10,7 @@ import (
"firebase.google.com/go/v4/messaging"
"fmt"
"google.golang.org/api/option"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"strings"
@@ -43,7 +44,7 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien
}
}
func (c *firebaseClient) Send(v *visitor, m *message) error {
func (c *firebaseClient) Send(v *visitor, m *model.Message) error {
if !v.FirebaseAllowed() {
return errFirebaseTemporarilyBanned
}
@@ -121,11 +122,11 @@ func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
// to Firebase here. This is mainly for iOS to support self-hosted servers.
func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, error) {
func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message, error) {
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
var apnsConfig *messaging.APNSConfig
switch m.Event {
case keepaliveEvent, openEvent:
case model.KeepaliveEvent, model.OpenEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
@@ -133,7 +134,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"topic": m.Topic,
}
apnsConfig = createAPNSBackgroundConfig(data)
case pollRequestEvent:
case model.PollRequestEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
@@ -143,7 +144,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"poll_id": m.PollID,
}
apnsConfig = createAPNSAlertConfig(m, data)
case messageDeleteEvent, messageClearEvent:
case model.MessageDeleteEvent, model.MessageClearEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
@@ -152,7 +153,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
"sequence_id": m.SequenceID,
}
apnsConfig = createAPNSBackgroundConfig(data)
case messageEvent:
case model.MessageEvent:
if auther != nil {
// If "anonymous read" for a topic is not allowed, we cannot send the message along
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
@@ -235,7 +236,7 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
// Extension in iOS can modify the message.
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
func createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging.APNSConfig {
apnsData := make(map[string]any)
for k, v := range data {
apnsData[k] = v
@@ -296,8 +297,8 @@ func maybeTruncateAPNSBodyMessage(s string) string {
//
// This empties all the fields that are not needed for a poll request and just sets the required fields,
// most importantly, the PollID.
func toPollRequest(m *message) *message {
pr := newPollRequestMessage(m.Topic, m.ID)
func toPollRequest(m *model.Message) *model.Message {
pr := model.NewPollRequestMessage(m.Topic, m.ID)
pr.ID = m.ID
pr.Time = m.Time
pr.Priority = m.Priority // Keep priority

View File

@@ -4,6 +4,7 @@ package server
import (
"errors"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
)
@@ -21,7 +22,7 @@ var (
type firebaseClient struct {
}
func (c *firebaseClient) Send(v *visitor, m *message) error {
func (c *firebaseClient) Send(v *visitor, m *model.Message) error {
return errFirebaseNotAvailable
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"net/netip"
"strings"
@@ -63,7 +64,7 @@ func (s *testFirebaseSender) Messages() []*messaging.Message {
}
func TestToFirebaseMessage_Keepalive(t *testing.T) {
m := newKeepaliveMessage("mytopic")
m := model.NewKeepaliveMessage("mytopic")
fbm, err := toFirebaseMessage(m, nil)
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
@@ -94,7 +95,7 @@ func TestToFirebaseMessage_Keepalive(t *testing.T) {
}
func TestToFirebaseMessage_Open(t *testing.T) {
m := newOpenMessage("mytopic")
m := model.NewOpenMessage("mytopic")
fbm, err := toFirebaseMessage(m, nil)
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
@@ -125,13 +126,13 @@ func TestToFirebaseMessage_Open(t *testing.T) {
}
func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
m := newDefaultMessage("mytopic", "this is a message")
m := model.NewDefaultMessage("mytopic", "this is a message")
m.Priority = 4
m.Tags = []string{"tag 1", "tag2"}
m.Click = "https://google.com"
m.Icon = "https://ntfy.sh/static/img/ntfy.png"
m.Title = "some title"
m.Actions = []*action{
m.Actions = []*model.Action{
{
ID: "123",
Action: "view",
@@ -150,7 +151,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
},
},
}
m.Attachment = &attachment{
m.Attachment = &model.Attachment{
Name: "some file.jpg",
Type: "image/jpeg",
Size: 12345,
@@ -219,7 +220,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
}
func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
m := newDefaultMessage("mytopic", "this is a message")
m := model.NewDefaultMessage("mytopic", "this is a message")
m.Priority = 5
fbm, err := toFirebaseMessage(m, &testAuther{Allow: false}) // Not allowed!
require.Nil(t, err)
@@ -250,7 +251,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
}
func TestToFirebaseMessage_PollRequest(t *testing.T) {
m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6")
m := model.NewPollRequestMessage("mytopic", "fOv6k1QbCzo6")
fbm, err := toFirebaseMessage(m, nil)
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
@@ -344,18 +345,18 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
func TestToFirebaseSender_Abuse(t *testing.T) {
sender := &testFirebaseSender{allowed: 2}
client := newFirebaseClient(sender, &testAuther{})
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil)
visitor := newVisitor(newTestConfig(t, ""), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil)
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"}))
require.Equal(t, 1, len(sender.Messages()))
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"}))
require.Equal(t, 2, len(sender.Messages()))
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &model.Message{Topic: "mytopic"}))
require.Equal(t, 2, len(sender.Messages()))
sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"}))
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &model.Message{Topic: "mytopic"}))
require.Equal(t, 0, len(sender.Messages()))
}

View File

@@ -17,15 +17,10 @@ func (s *Server) execManager() {
s.pruneMessages()
s.pruneAndNotifyWebPushSubscriptions()
// Message count per topic
var messagesCached int
messageCounts, err := s.messageCache.MessageCounts()
// Message count
messagesCached, err := s.messageCache.MessagesCount()
if err != nil {
log.Tag(tagManager).Err(err).Warn("Cannot get message counts")
messageCounts = make(map[string]int) // Empty, so we can continue
}
for _, count := range messageCounts {
messagesCached += count
log.Tag(tagManager).Err(err).Warn("Cannot get messages count")
}
// Remove subscriptions without subscribers

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
//go:build !race
package server
const raceEnabled = false

View File

@@ -0,0 +1,5 @@
//go:build race
package server
const raceEnabled = true

File diff suppressed because one or more lines are too long

View File

@@ -4,33 +4,50 @@ import (
"bytes"
"encoding/xml"
"fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/url"
"strings"
"text/template"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
const (
twilioCallFormat = `
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
// It can be overridden in the server configuration's twilio-call-format field.
//
// The format uses Go template syntax with the following fields:
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
// String fields are automatically XML-escaped.
var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
<Response>
<Pause length="1"/>
<Say loop="3">
You have a message from notify on topic %s. Message:
You have a message from notify on topic {{.Topic}}. Message:
<break time="1s"/>
%s
{{.Message}}
<break time="1s"/>
End of message.
<break time="1s"/>
This message was sent by user %s. It will be repeated three times.
This message was sent by user {{.Sender}}. It will be repeated three times.
To unsubscribe from calls like this, remove your phone number in the notify web app.
<break time="3s"/>
</Say>
<Say>Goodbye.</Say>
</Response>`
)
</Response>`))
// twilioCallData holds the data passed to the Twilio call format template
type twilioCallData struct {
Topic string
Title string
Message string
Priority int
Tags []string
Sender string
}
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
@@ -60,12 +77,34 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
// Failures will be logged, but not returned to the caller.
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
u, sender := v.User(), m.Sender.String()
if u != nil {
sender = u.Name
}
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
tmpl := defaultTwilioCallFormatTemplate
if s.config.TwilioCallFormat != nil {
tmpl = s.config.TwilioCallFormat
}
tags := make([]string, len(m.Tags))
for i, tag := range m.Tags {
tags[i] = xmlEscapeText(tag)
}
templateData := &twilioCallData{
Topic: xmlEscapeText(m.Topic),
Title: xmlEscapeText(m.Title),
Message: xmlEscapeText(m.Message),
Priority: m.Priority,
Tags: tags,
Sender: xmlEscapeText(sender),
}
var bodyBuf bytes.Buffer
if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
minc(metricCallsMadeFailure)
return
}
body := bodyBuf.String()
data := url.Values{}
data.Set("From", s.config.TwilioPhoneNumber)
data.Set("To", to)
@@ -87,7 +126,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
@@ -111,7 +150,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
@@ -137,7 +176,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)

View File

@@ -1,264 +1,343 @@
package server
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"text/template"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
var called, verified atomic.Bool
var code atomic.Pointer[string]
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
if code.Load() != nil {
forEachBackend(t, func(t *testing.T, databaseURL string) {
var called, verified atomic.Bool
var code atomic.Pointer[string]
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
if code.Load() != nil {
t.Fatal("Should be only called once")
}
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
code.Store(util.String("123456"))
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
if verified.Load() {
t.Fatal("Should be only called once")
}
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
verified.Store(true)
} else {
t.Fatal("Unexpected path:", r.URL.Path)
}
}))
defer twilioVerifyServer.Close()
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
code.Store(util.String("123456"))
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
if verified.Load() {
t.Fatal("Should be only called once")
}
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
verified.Store(true)
} else {
t.Fatal("Unexpected path:", r.URL.Path)
}
}))
defer twilioVerifyServer.Close()
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioCallsServer.Close()
c := newTestConfigWithAuthFile(t, databaseURL)
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
c.TwilioCallsBaseURL = twilioCallsServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
c.TwilioVerifyService = "VA1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioCallsServer.Close()
c := newTestConfigWithAuthFile(t)
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
c.TwilioCallsBaseURL = twilioCallsServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
c.TwilioVerifyService = "VA1234567890"
s := newTestServer(t, c)
// Send verification code for phone number
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return *code.Load() == "123456"
})
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
// Add phone number with code
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return verified.Load()
})
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 1, len(phoneNumbers))
require.Equal(t, "+12223334444", phoneNumbers[0])
// Send verification code for phone number
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return *code.Load() == "123456"
})
// Do the thing
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
// Add phone number with code
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
waitFor(t, func() bool {
return verified.Load()
})
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 1, len(phoneNumbers))
require.Equal(t, "+12223334444", phoneNumbers[0])
// Remove the phone number
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// Do the thing
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes",
// Verify the phone number is gone from the DB
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 0, len(phoneNumbers))
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
// Remove the phone number
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// Verify the phone number is gone from the DB
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
require.Nil(t, err)
require.Equal(t, 0, len(phoneNumbers))
}
func TestServer_Twilio_Call_Success(t *testing.T) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
forEachBackend(t, func(t *testing.T, databaseURL string) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t, databaseURL)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
})
}
func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
forEachBackend(t, func(t *testing.T, databaseURL string) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t, databaseURL)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes", // <<<------
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "yes", // <<<------
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
}
func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
var called atomic.Bool
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if called.Load() {
t.Fatal("Should be only called once")
}
body, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
called.Store(true)
}))
defer twilioServer.Close()
c := newTestConfigWithAuthFile(t, databaseURL)
c.TwilioCallsBaseURL = twilioServer.URL
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
c.TwilioCallFormat = template.Must(template.New("twiml").Parse(`
<Response>
<Pause length="1"/>
<Say language="de-DE" loop="3">
Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
Ende der Nachricht.
<break time="1s"/>
Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app.
<break time="3s"/>
</Say>
<Say language="de-DE">Auf Wiederhören.</Say>
</Response>`))
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
waitFor(t, func() bool {
return called.Load()
})
})
}
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "http://dummy.invalid"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.TwilioCallsBaseURL = "http://dummy.invalid"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Add tier and user
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 10,
CallLimit: 1,
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
// Do the thing
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
// Do the thing
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"authorization": util.BasicAuth("phil", "phil"),
"x-call": "+11122233344",
})
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
})
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+invalid",
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+invalid",
})
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
})
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_Anonymous(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfigWithAuthFile(t, databaseURL)
c.TwilioCallsBaseURL = "https://127.0.0.1"
c.TwilioAccount = "AC1234567890"
c.TwilioAuthToken = "AAEAA1234567890"
c.TwilioPhoneNumber = "+1234567890"
s := newTestServer(t, c)
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+123123",
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+123123",
})
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
})
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_Twilio_Call_Unconfigured(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+1234",
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"x-call": "+1234",
})
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
})
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
}

View File

@@ -11,7 +11,9 @@ import (
"github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
wpush "heckel.io/ntfy/v2/webpush"
)
const (
@@ -82,14 +84,14 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {
subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
if err != nil {
logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages")
return
}
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON()))
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.ForJSON()))
if err != nil {
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
return
@@ -128,7 +130,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
if err != nil {
return err
}
warningSent := make([]*webPushSubscription, 0)
warningSent := make([]*wpush.Subscription, 0)
for _, subscription := range subscriptions {
if err := s.sendWebPushNotification(subscription, payload); err != nil {
log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning")
@@ -143,7 +145,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
return nil
}
func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error {
func (s *Server) sendWebPushNotification(sub *wpush.Subscription, message []byte, contexters ...log.Contexter) error {
log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message")
payload := &webpush.Subscription{
Endpoint: sub.Endpoint,

View File

@@ -4,6 +4,8 @@ package server
import (
"net/http"
"heckel.io/ntfy/v2/model"
)
const (
@@ -20,7 +22,7 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *
return errHTTPNotFound
}
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {
// Nothing to see here
}

View File

@@ -5,10 +5,6 @@ package server
import (
"encoding/json"
"fmt"
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io"
"net/http"
"net/http/httptest"
@@ -18,6 +14,11 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
const (
@@ -25,237 +26,262 @@ const (
)
func TestServer_WebPush_Enabled(t *testing.T) {
conf := newTestConfig(t)
conf.WebRoot = "" // Disable web app
s := newTestServer(t, conf)
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfig(t, databaseURL)
conf.WebRoot = "" // Disable web app
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf2 := newTestConfig(t)
s2 := newTestServer(t, conf2)
conf2 := newTestConfig(t, databaseURL)
s2 := newTestServer(t, conf2)
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf3 := newTestConfigWithWebPush(t)
s3 := newTestServer(t, conf3)
conf3 := newTestConfigWithWebPush(t, databaseURL)
s3 := newTestServer(t, conf3)
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 200, rr.Code)
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 200, rr.Code)
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
})
}
func TestServer_WebPush_Disabled(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 404, response.Code)
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 404, response.Code)
})
}
func TestServer_WebPush_TopicAdd(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
require.Equal(t, subs[0].P256dh, "p256dh-key")
require.Equal(t, subs[0].Auth, "auth-key")
require.Equal(t, subs[0].UserID, "")
require.Len(t, subs, 1)
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
require.Equal(t, subs[0].P256dh, "p256dh-key")
require.Equal(t, subs[0].Auth, "auth-key")
require.Equal(t, subs[0].UserID, "")
})
}
func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
require.Equal(t, 400, response.Code)
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
require.Equal(t, 400, response.Code)
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
})
}
func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
topicList := make([]string, 51)
for i := range topicList {
topicList[i] = util.RandomString(5)
}
topicList := make([]string, 51)
for i := range topicList {
topicList[i] = util.RandomString(5)
}
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
require.Equal(t, 400, response.Code)
require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
require.Equal(t, 400, response.Code)
require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
})
}
func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
addSubscription(t, s, testWebPushEndpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
addSubscription(t, s, testWebPushEndpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 0)
requireSubscriptionCount(t, s, "test-topic", 0)
})
}
func TestServer_WebPush_Delete(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
addSubscription(t, s, testWebPushEndpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
addSubscription(t, s, testWebPushEndpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 0)
requireSubscriptionCount(t, s, "test-topic", 0)
})
}
func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
forEachBackend(t, func(t *testing.T, databaseURL string) {
config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
})
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
}
func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
forEachBackend(t, func(t *testing.T, databaseURL string) {
config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 403, response.Code)
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 403, response.Code)
requireSubscriptionCount(t, s, "test-topic", 0)
requireSubscriptionCount(t, s, "test-topic", 0)
})
}
func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
s := newTestServer(t, config)
forEachBackend(t, func(t *testing.T, databaseURL string) {
config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))
s := newTestServer(t, config)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 1)
request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
// should've been deleted with the account
requireSubscriptionCount(t, s, "test-topic", 0)
})
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 1)
request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
// should've been deleted with the account
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_Publish(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/push-receive", r.URL.Path)
require.Equal(t, "high", r.Header.Get("Urgency"))
require.Equal(t, "", r.Header.Get("Topic"))
received.Store(true)
}))
defer pushService.Close()
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/push-receive", r.URL.Path)
require.Equal(t, "high", r.Header.Get("Urgency"))
require.Equal(t, "", r.Header.Get("Topic"))
received.Store(true)
}))
defer pushService.Close()
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
request(t, s, "POST", "/test-topic", "web push test", nil)
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
request(t, s, "POST", "/test-topic", "web push test", nil)
waitFor(t, func() bool {
return received.Load()
waitFor(t, func() bool {
return received.Load()
})
})
}
func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
w.WriteHeader(http.StatusGone)
received.Store(true)
}))
defer pushService.Close()
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
w.WriteHeader(http.StatusGone)
received.Store(true)
}))
defer pushService.Close()
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
requireSubscriptionCount(t, s, "test-topic", 1)
requireSubscriptionCount(t, s, "test-topic-abc", 1)
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
requireSubscriptionCount(t, s, "test-topic", 1)
requireSubscriptionCount(t, s, "test-topic-abc", 1)
request(t, s, "POST", "/test-topic", "web push test", nil)
request(t, s, "POST", "/test-topic", "web push test", nil)
waitFor(t, func() bool {
return received.Load()
waitFor(t, func() bool {
return received.Load()
})
// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
requireSubscriptionCount(t, s, "test-topic", 0)
requireSubscriptionCount(t, s, "test-topic-abc", 0)
})
// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
requireSubscriptionCount(t, s, "test-topic", 0)
requireSubscriptionCount(t, s, "test-topic-abc", 0)
}
func TestServer_WebPush_Expiry(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
var received atomic.Bool
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
w.WriteHeader(200)
w.Write([]byte(``))
received.Store(true)
}))
defer pushService.Close()
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
w.WriteHeader(200)
w.Write([]byte(``))
received.Store(true)
}))
defer pushService.Close()
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
endpoint := pushService.URL + "/push-receive"
addSubscription(t, s, endpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix())
require.Nil(t, err)
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix()))
s.pruneAndNotifyWebPushSubscriptions()
requireSubscriptionCount(t, s, "test-topic", 1)
s.pruneAndNotifyWebPushSubscriptions()
requireSubscriptionCount(t, s, "test-topic", 1)
waitFor(t, func() bool {
return received.Load()
})
waitFor(t, func() bool {
return received.Load()
})
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix())
require.Nil(t, err)
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix()))
s.pruneAndNotifyWebPushSubscriptions()
waitFor(t, func() bool {
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
return len(subs) == 0
s.pruneAndNotifyWebPushSubscriptions()
waitFor(t, func() bool {
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
return len(subs) == 0
})
})
}
@@ -281,11 +307,13 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen
require.Len(t, subs, expectedLength)
}
func newTestConfigWithWebPush(t *testing.T) *Config {
conf := newTestConfig(t)
func newTestConfigWithWebPush(t *testing.T, databaseURL string) *Config {
conf := newTestConfig(t, databaseURL)
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
require.Nil(t, err)
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
if conf.DatabaseURL == "" {
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
}
conf.WebPushEmailAddress = "testing@example.com"
conf.WebPushPrivateKey = privateKey
conf.WebPushPublicKey = publicKey

View File

@@ -12,11 +12,12 @@ import (
"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
Send(v *visitor, m *model.Message, to string) error
Counts() (total int64, success int64, failure int64)
}
@@ -27,7 +28,7 @@ type smtpSender struct {
mu sync.Mutex
}
func (s *smtpSender) Send(v *visitor, m *message, to string) error {
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
return s.withCount(v, m, func() error {
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
if err != nil {
@@ -63,7 +64,7 @@ func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
return s.success + s.failure, s.success, s.failure
}
func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error {
err := fn()
s.mu.Lock()
defer s.mu.Unlock()
@@ -76,7 +77,7 @@ func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) 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 == "" {

View File

@@ -1,12 +1,14 @@
package server
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",

View File

@@ -19,6 +19,7 @@ import (
"github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday"
"heckel.io/ntfy/v2/model"
)
var (
@@ -33,6 +34,7 @@ var (
var (
onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
htmlLineBreakRegex = regexp.MustCompile(`(?i)<br\s*/?>`)
)
const (
@@ -158,7 +160,7 @@ func (s *smtpSession) Data(r io.Reader) error {
if len(body) > conf.MessageSizeLimit {
body = body[:conf.MessageSizeLimit]
}
m := newDefaultMessage(s.topic, body)
m := model.NewDefaultMessage(s.topic, body)
subject := strings.TrimSpace(msg.Header.Get("Subject"))
if subject != "" {
dec := mime.WordDecoder{}
@@ -183,7 +185,7 @@ func (s *smtpSession) Data(r io.Reader) error {
})
}
func (s *smtpSession) publishMessage(m *message) error {
func (s *smtpSession) publishMessage(m *model.Message) error {
// Extract remote address (for rate limiting)
remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
if err != nil {
@@ -327,6 +329,9 @@ func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error)
if err != nil {
return "", err
}
// Convert <br> tags to newlines before stripping HTML, so that line breaks
// in HTML emails (e.g. from Synology DSM, and other appliances) are preserved.
body = htmlLineBreakRegex.ReplaceAllString(body, "\n")
stripped := bluemonday.
StrictPolicy().
AddSpaceWhenStrippingTag(true).

View File

@@ -694,7 +694,8 @@ home automation setup
Now the light is on
If you don&#39;t want to receive this message anymore, stop the push
services in your FRITZ!Box .
services in your FRITZ!Box .
Here you can see the active push services: &#34;System &gt; Push Service&#34;.
This mail has ben sent by your FRITZ!Box automatically.`
@@ -1354,9 +1355,11 @@ Congratulations! You have successfully set up the email notification on Synology
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/synology", r.URL.Path)
require.Equal(t, "[Synology NAS] Test Message from Litts_NAS", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := `Congratulations! You have successfully set up the email notification on Synology_NAS. For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/. (If you cannot connect to the server, please contact the administrator.) From Synology_NAS`
require.Equal(t, expected, actual)
expected := "Congratulations! You have successfully set up the email notification on Synology_NAS.\n" +
"For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.\n" +
"(If you cannot connect to the server, please contact the administrator.)\n\n" +
"From Synology_NAS"
require.Equal(t, expected, readAll(t, r.Body))
})
conf.SMTPServerDomain = "mydomain.me"
conf.SMTPServerAddrPrefix = ""
@@ -1365,6 +1368,36 @@ Congratulations! You have successfully set up the email notification on Synology
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_HTMLEmail_BrTagsPreserved(t *testing.T) {
email := `EHLO example.com
MAIL FROM: nas@example.com
RCPT TO: ntfy-alerts@ntfy.sh
DATA
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 8bit
Subject: Task Scheduler: daily-backup
Task Scheduler has completed a scheduled task.<BR><BR>Task: daily-backup<BR>Start time: Mon, 01 Jan 2026 02:00:00 +0000<BR>Stop time: Mon, 01 Jan 2024 02:03:00 +0000<BR>Current status: 0 (Normal)<BR>Standard output/error:<BR>OK<BR><BR>From MyNAS
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/alerts", r.URL.Path)
require.Equal(t, "Task Scheduler: daily-backup", r.Header.Get("Title"))
expected := "Task Scheduler has completed a scheduled task.\n\n" +
"Task: daily-backup\n" +
"Start time: Mon, 01 Jan 2026 02:00:00 +0000\n" +
"Stop time: Mon, 01 Jan 2024 02:03:00 +0000\n" +
"Current status: 0 (Normal)\n" +
"Standard output/error:\n" +
"OK\n\n" +
"From MyNAS"
require.Equal(t, expected, readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com
@@ -1411,7 +1444,7 @@ what's up
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
conf = newTestConfig(t)
conf = newTestConfig(t, "")
conf.SMTPServerListen = ":25"
conf.SMTPServerDomain = "ntfy.sh"
conf.SMTPServerAddrPrefix = "ntfy-"

View File

@@ -6,6 +6,7 @@ import (
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util"
)
@@ -33,7 +34,7 @@ type topicSubscriber struct {
}
// subscriber is a function that is called for every new message on a topic
type subscriber func(v *visitor, msg *message) error
type subscriber func(v *visitor, msg *model.Message) error
// newTopic creates a new topic
func newTopic(id string) *topic {
@@ -103,7 +104,7 @@ func (t *topic) Unsubscribe(id int) {
}
// Publish asynchronously publishes to all subscribers
func (t *topic) Publish(v *visitor, m *message) error {
func (t *topic) Publish(v *visitor, m *model.Message) error {
go func() {
// We want to lock the topic as short as possible, so we make a shallow copy of the
// subscribers map here. Actually sending out the messages then doesn't have to lock.

View File

@@ -7,10 +7,11 @@ import (
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/model"
)
func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
subFn := func(v *visitor, msg *message) error {
subFn := func(v *visitor, msg *model.Message) error {
return nil
}
canceled1 := atomic.Bool{}
@@ -33,7 +34,7 @@ func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
func TestTopic_CancelSubscribersUser(t *testing.T) {
t.Parallel()
subFn := func(v *visitor, msg *message) error {
subFn := func(v *visitor, msg *model.Message) error {
return nil
}
canceled1 := atomic.Bool{}
@@ -76,7 +77,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) {
cancel: func() {},
}
subFn := func(v *visitor, msg *message) error {
subFn := func(v *visitor, msg *model.Message) error {
return nil
}

View File

@@ -2,218 +2,35 @@ package server
import (
"net/http"
"net/netip"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"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"
)
const (
messageIDLength = 12
)
// 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
}
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
}
// 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
}
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"`
}
type action struct {
ID string `json:"id"`
Action string `json:"action"` // "view", "broadcast", or "http"
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
}
func newAction() *action {
return &action{
Headers: make(map[string]string),
Extras: make(map[string]string),
}
}
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
Topic string `json:"topic"`
SequenceID string `json:"sequence_id"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags"`
Click string `json:"click"`
Icon string `json:"icon"`
Actions []action `json:"actions"`
Attach string `json:"attach"`
Markdown bool `json:"markdown"`
Filename string `json:"filename"`
Email string `json:"email"`
Call string `json:"call"`
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
Delay string `json:"delay"`
Topic string `json:"topic"`
SequenceID string `json:"sequence_id"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags"`
Click string `json:"click"`
Icon string `json:"icon"`
Actions []model.Action `json:"actions"`
Attach string `json:"attach"`
Markdown bool `json:"markdown"`
Filename string `json:"filename"`
Email string `json:"email"`
Call string `json:"call"`
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
Delay string `json:"delay"`
}
// messageEncoder is a function that knows how to encode a message
type messageEncoder func(msg *message) (string, error)
// newMessage creates a new message with the current timestamp
func newMessage(event, topic, msg string) *message {
return &message{
ID: util.RandomString(messageIDLength),
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)
}
// newPollRequestMessage is a convenience method to create a poll request message
func newPollRequestMessage(topic, pollID string) *message {
m := newMessage(pollRequestEvent, topic, newMessageBody)
m.PollID = pollID
return m
}
// 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
}
func validMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength)
}
type sinceMarker struct {
time time.Time
id string
}
func newSinceTime(timestamp int64) sinceMarker {
return sinceMarker{time.Unix(timestamp, 0), ""}
}
func newSinceID(id string) sinceMarker {
return sinceMarker{time.Unix(0, 0), id}
}
func (t sinceMarker) IsAll() bool {
return t == sinceAllMessages
}
func (t sinceMarker) IsNone() bool {
return t == sinceNoMessages
}
func (t sinceMarker) IsLatest() bool {
return t == sinceLatestMessage
}
func (t sinceMarker) IsID() bool {
return t.id != "" && t.id != "latest"
}
func (t sinceMarker) Time() time.Time {
return t.time
}
func (t sinceMarker) ID() string {
return t.id
}
var (
sinceAllMessages = sinceMarker{time.Unix(0, 0), ""}
sinceNoMessages = sinceMarker{time.Unix(1, 0), ""}
sinceLatestMessage = sinceMarker{time.Unix(0, 0), "latest"}
)
type messageEncoder func(msg *model.Message) (string, error)
type queryFilter struct {
ID string
@@ -245,8 +62,8 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
}, nil
}
func (q *queryFilter) Pass(msg *message) bool {
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
func (q *queryFilter) Pass(msg *model.Message) bool {
if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {
return true // filters only apply to messages
} else if q.ID != "" && msg.ID != q.ID {
return false
@@ -299,7 +116,7 @@ func (t templateMode) FileName() string {
return ""
}
// templateFile represents a template file with title and message
// templateFile represents a template file with title, message, and priority
// It is used for file-based templates, e.g. grafana, influxdb, etc.
//
// Example YAML:
@@ -308,15 +125,23 @@ func (t templateMode) FileName() string {
// message: |
// This is a {{ .Type }} alert.
// It can be multiline.
// priority: '{{ if eq .status "Error" }}5{{ else }}3{{ end }}'
type templateFile struct {
Title *string `yaml:"title"`
Message *string `yaml:"message"`
Title *string `yaml:"title"`
Message *string `yaml:"message"`
Priority *string `yaml:"priority"`
}
type apiHealthResponse struct {
Healthy bool `json:"healthy"`
}
type apiVersionResponse struct {
Version string `json:"version"`
Commit string `json:"commit"`
Date string `json:"date"`
}
type apiStatsResponse struct {
Messages int64 `json:"messages"`
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
@@ -482,6 +307,7 @@ type apiConfigResponse struct {
BillingContact string `json:"billing_contact"`
WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
ConfigHash string `json:"config_hash"`
}
type apiAccountBillingPrices struct {
@@ -560,12 +386,12 @@ const (
)
type webPushPayload struct {
Event string `json:"event"`
SubscriptionID string `json:"subscription_id"`
Message *message `json:"message"`
Event string `json:"event"`
SubscriptionID string `json:"subscription_id"`
Message *model.Message `json:"message"`
}
func newWebPushPayload(subscriptionID string, message *message) *webPushPayload {
func newWebPushPayload(subscriptionID string, message *model.Message) *webPushPayload {
return &webPushPayload{
Event: webPushMessageEvent,
SubscriptionID: subscriptionID,
@@ -583,22 +409,6 @@ func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {
}
}
type webPushSubscription struct {
ID string
Endpoint string
Auth string
P256dh string
UserID string
}
func (w *webPushSubscription) Context() log.Context {
return map[string]any{
"web_push_subscription_id": w.ID,
"web_push_subscription_user_id": w.UserID,
"web_push_subscription_endpoint": w.Endpoint,
}
}
// https://developer.mozilla.org/en-US/docs/Web/Manifest
type webManifestResponse struct {
Name string `json:"name"`

View File

@@ -8,6 +8,7 @@ import (
"golang.org/x/time/rate"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -53,7 +54,7 @@ const (
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
type visitor struct {
config *Config
messageCache *messageCache
messageCache *message.Cache
userManager *user.Manager // May be nil
ip netip.Addr // Visitor IP address
user *user.User // Only set if authenticated user, otherwise nil
@@ -114,7 +115,7 @@ const (
visitorLimitBasisTier = visitorLimitBasis("tier")
)
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
var messages, emails, calls int64
if user != nil {
messages = user.Stats.Messages

View File

@@ -1,285 +0,0 @@
package server
import (
"database/sql"
"errors"
"heckel.io/ntfy/v2/util"
"net/netip"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
const (
subscriptionIDPrefix = "wps_"
subscriptionIDLength = 10
subscriptionEndpointLimitPerSubscriberIP = 10
)
var (
errWebPushNoRows = errors.New("no rows found")
errWebPushTooManySubscriptions = errors.New("too many subscriptions")
errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty")
)
const (
createWebPushSubscriptionsTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
COMMIT;
`
builtinStartupQueries = `
PRAGMA foreign_keys = ON;
`
selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`
selectWebPushSubscriptionsForTopicQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription_topic st
JOIN subscription s ON s.id = st.subscription_id
WHERE st.topic = ?
ORDER BY endpoint
`
selectWebPushSubscriptionsExpiringSoonQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription
WHERE warned_at = 0 AND updated_at <= ?
`
insertWebPushSubscriptionQuery = `
INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint)
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
`
updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?`
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
deleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
)
// Schema management queries
const (
currentWebPushSchemaVersion = 1
insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
type webPushStore struct {
db *sql.DB
}
func newWebPushStore(filename, startupQueries string) (*webPushStore, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupWebPushDB(db); err != nil {
return nil, err
}
if err := runWebPushStartupQueries(db, startupQueries); err != nil {
return nil, err
}
return &webPushStore{
db: db,
}, nil
}
func setupWebPushDB(db *sql.DB) error {
// If 'schemaVersion' table does not exist, this must be a new database
rows, err := db.Query(selectWebPushSchemaVersionQuery)
if err != nil {
return setupNewWebPushDB(db)
}
return rows.Close()
}
func setupNewWebPushDB(db *sql.DB) error {
if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil {
return err
}
return nil
}
func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
if _, err := db.Exec(builtinStartupQueries); err != nil {
return err
}
return nil
}
// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all
// existing entries for a given endpoint.
func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Read number of subscriptions for subscriber IP address
rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String())
if err != nil {
return err
}
defer rowsCount.Close()
var subscriptionCount int
if !rowsCount.Next() {
return errWebPushNoRows
}
if err := rowsCount.Scan(&subscriptionCount); err != nil {
return err
}
if err := rowsCount.Close(); err != nil {
return err
}
// Read existing subscription ID for endpoint (or create new ID)
rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint)
if err != nil {
return err
}
defer rows.Close()
var subscriptionID string
if rows.Next() {
if err := rows.Scan(&subscriptionID); err != nil {
return err
}
} else {
if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {
return errWebPushTooManySubscriptions
}
subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
}
if err := rows.Close(); err != nil {
return err
}
// Insert or update subscription
updatedAt, warnedAt := time.Now().Unix(), 0
if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
return err
}
// Replace all subscription topics
if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil {
return err
}
for _, topic := range topics {
if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil {
return err
}
}
return tx.Commit()
}
// SubscriptionsForTopic returns all subscriptions for the given topic
func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) {
rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
if err != nil {
return nil, err
}
defer rows.Close()
return c.subscriptionsFromRows(rows)
}
// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period
func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) {
rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix())
if err != nil {
return nil, err
}
defer rows.Close()
return c.subscriptionsFromRows(rows)
}
// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon
func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, subscription := range subscriptions {
if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil {
return err
}
}
return tx.Commit()
}
func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) {
subscriptions := make([]*webPushSubscription, 0)
for rows.Next() {
var id, endpoint, auth, p256dh, userID string
if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {
return nil, err
}
subscriptions = append(subscriptions, &webPushSubscription{
ID: id,
Endpoint: endpoint,
Auth: auth,
P256dh: p256dh,
UserID: userID,
})
}
return subscriptions, nil
}
// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint
func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error {
_, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint)
return err
}
// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID
func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
if userID == "" {
return errWebPushUserIDCannotBeEmpty
}
_, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID)
return err
}
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
_, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
if err != nil {
return err
}
_, err = c.db.Exec(deleteWebPushSubscriptionTopicWithoutSubscription)
return err
}
// Close closes the underlying database connection
func (c *webPushStore) Close() error {
return c.db.Close()
}

View File

@@ -1,199 +0,0 @@
package server
import (
"fmt"
"github.com/stretchr/testify/require"
"net/netip"
"path/filepath"
"testing"
"time"
)
func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
subs, err := webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
require.Equal(t, subs[0].P256dh, "p256dh-key")
require.Equal(t, subs[0].Auth, "auth-key")
require.Equal(t, subs[0].UserID, "u_1234")
subs2, err := webPush.SubscriptionsForTopic("mytopic")
require.Nil(t, err)
require.Len(t, subs2, 1)
require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)
}
func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert 10 subscriptions with the same IP address
for i := 0; i < 10; i++ {
endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i)
require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
}
// Another one for the same endpoint should be fine
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different endpoint it should fail
require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different IP address it should be fine again
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"}))
}
func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics, and another with one topic
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint)
subs, err = webPush.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
// Update the first subscription to have only one topic
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
subs, err = webPush.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234"))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID(""))
}
func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Mark them as warning sent
require.Nil(t, webPush.MarkExpiryWarningSent(subs))
rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0")
require.Nil(t, err)
defer rows.Close()
var endpoint string
require.True(t, rows.Next())
require.Nil(t, rows.Scan(&endpoint))
require.Nil(t, err)
require.Equal(t, testWebPushEndpoint, endpoint)
require.False(t, rows.Next())
}
func TestWebPushStore_SubscriptionsExpiring(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as soon-to-expire
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint)
require.Nil(t, err)
// Should not be cleaned up yet
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
// Run expiration
subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour)
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
}
func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as expired
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint)
require.Nil(t, err)
// Run expiration
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
// List again, should be 0
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func newTestWebPushStore(t *testing.T) *webPushStore {
webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "")
require.Nil(t, err)
return webPush
}

35
tools/pgimport/README.md Normal file
View File

@@ -0,0 +1,35 @@
# pgimport
Migrates ntfy data from SQLite to PostgreSQL.
## Build
```bash
go build -o pgimport ./tools/pgimport/
```
## Usage
```bash
# Using CLI flags
pgimport \
--database-url "postgres://user:pass@host:5432/ntfy?sslmode=require" \
--cache-file /var/cache/ntfy/cache.db \
--auth-file /var/lib/ntfy/user.db \
--web-push-file /var/lib/ntfy/webpush.db
# Using server.yml (flags override config values)
pgimport --config /etc/ntfy/server.yml
```
## Prerequisites
- PostgreSQL schema must already be set up (run ntfy with `database-url` once)
- ntfy must not be running during the import
- All three SQLite files are optional; only the ones specified will be imported
## Notes
- The tool is idempotent and safe to re-run
- After importing, row counts and content are verified against the SQLite sources
- Invalid UTF-8 in messages is replaced with the Unicode replacement character

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