Compare commits

...

148 Commits

Author SHA1 Message Date
binwiederhier
7d8407871c WIP Postgres message cache 2024-04-23 22:44:04 -04:00
binwiederhier
6eff5553b5 Merge branch 'main' of github.com:binwiederhier/ntfy 2024-04-23 20:22:16 -04:00
binwiederhier
7cac03c1ec Bump 2024-04-23 20:22:09 -04:00
Philipp C. Heckel
130039f5c8 Merge pull request #1071 from truroshan/traccar-example-add-info
Update traccar examples
2024-03-27 12:21:24 -04:00
Roshan Rajak l byteio.ɪn
bec0d4807b Update traccar examples 2024-03-27 18:39:31 +05:30
binwiederhier
5ee62033b5 Bump 2024-03-25 17:21:23 -04:00
binwiederhier
3e02d7b0bb Bump 2024-03-25 17:20:27 -04:00
binwiederhier
290ed1124e Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2024-03-25 17:12:04 -04:00
binwiederhier
fc62682334 Thank you @jceloria for your donation 2024-03-25 09:42:09 -04:00
binwiederhier
28404565d2 Thank you @Circenn5130 for your donation 2024-03-25 09:41:37 -04:00
binwiederhier
f8548e9d46 Thank you @tomroth04 for your donation 2024-03-25 09:41:08 -04:00
Philipp C. Heckel
d90b290cd2 Merge pull request #1066 from binwiederhier/templating-disallow
Disallow certain templates
2024-03-25 09:38:33 -04:00
binwiederhier
21c6776269 Fix linter 2024-03-25 09:34:44 -04:00
binwiederhier
7fed392e0c Disallow certain templates 2024-03-24 23:19:16 -04:00
Philipp C. Heckel
913b59b5e3 Merge pull request #1064 from binwiederhier/templating-3
Message templating
2024-03-24 14:47:32 -04:00
binwiederhier
4692ca7b7f REvert parallel tests 2024-03-24 14:36:14 -04:00
binwiederhier
af16542d02 Removed unused vars 2024-03-24 14:28:10 -04:00
binwiederhier
5511812e30 JSON templating 2024-03-24 14:21:28 -04:00
binwiederhier
547b09a7e5 docs 2024-03-23 14:22:56 -04:00
binwiederhier
b9c176ddba Tests 2024-03-22 22:01:41 -04:00
Philipp C. Heckel
f971377cbb Merge pull request #1062 from theparadox1083/patch-1
Update publish.md (PowerShell 7+ Access Token Example)
2024-03-22 21:43:00 -04:00
binwiederhier
a04f2f9c9a Bla 2024-03-22 20:45:16 -04:00
Patrick
763eafd5dd Update publish.md (PowerShell 7+ Access Token Example)
Corrected PowerShell 7+ Parameter from Authorization to Authentication
Converted Token text to SecureString Inline - token must be of type SecureString
Added comment noting the Token parameter must be a SecureString
2024-03-21 09:38:55 -04:00
binwiederhier
9247dac50d Move things, revert naming 2024-03-20 21:39:17 -04:00
binwiederhier
de65d07518 Simplify(?) templating cases 2024-03-20 21:33:54 -04:00
binwiederhier
1966f80855 Merge branch 'main' into templating 2024-03-20 20:27:12 -04:00
binwiederhier
4b2e38320d BUmp 2024-03-20 20:26:18 -04:00
wunter8
83356f565e remove debug print statement 2024-03-20 10:54:41 -06:00
Hunter Kehoe
7fd5f0b29d allow large HTTP body so long as resulting message is small 2024-03-19 21:56:55 -06:00
Hunter Kehoe
03737dbf5c update docs 2024-03-19 20:55:36 -06:00
Hunter Kehoe
867cf28080 refactor gjson parsing code 2024-03-19 20:30:24 -06:00
Hunter Kehoe
b2eb5b94bd use existing message and title fields for templates 2024-03-18 20:04:40 -06:00
Hunter Kehoe
df7d6baec5 add templating for title and message fields 2024-03-17 21:55:50 -06:00
josé m
a4f5c8dee7 Translated using Weblate (Galician)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/gl/
2024-03-12 13:01:53 +01:00
binwiederhier
4c0ec3f75b Thank you @arthurgleckler for your generous donation 2024-03-10 22:47:19 -04:00
binwiederhier
12bbe9a1ae Thank you @danbartram for your sponsorship 2024-03-10 22:44:43 -04:00
Mazurky
0a589f6242 Translated using Weblate (Slovak)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sk/
2024-03-10 17:01:44 +01:00
gallegonovato
ab2dd6136e Translated using Weblate (Spanish)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2024-03-10 17:01:44 +01:00
gallegonovato
4d64515e45 Translated using Weblate (Spanish)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2024-03-09 16:15:59 +01:00
LiLPandemio
411597ecc2 Translated using Weblate (Spanish)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2024-03-09 16:15:59 +01:00
Oğuz Ersen
1a426da913 Translated using Weblate (Turkish)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2024-03-09 16:15:59 +01:00
Philipp C. Heckel
7936c38feb Update README.md 2024-03-08 12:32:37 -05:00
binwiederhier
d0beaa900f Thank you @stannynuytkens for your sponsorship 2024-03-08 11:12:55 -05:00
Linerly
f4bf8fd9bb Translated using Weblate (Indonesian)
Currently translated at 99.7% (404 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2024-03-08 07:42:48 +01:00
109247019824
d866cb2fd9 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (405 of 405 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2024-03-08 07:42:47 +01:00
binwiederhier
0ab6171962 Blog posts 2024-03-07 23:07:07 -05:00
binwiederhier
b7c2898b9c Thank you @dav23r for your sponsorship 2024-03-07 21:24:33 -05:00
binwiederhier
d155ebb3a4 Thank you @TheTomik1 for your donation 2024-03-07 21:24:07 -05:00
binwiederhier
3d5bb92774 Thank you @Integral-Tech for your sponsorship 2024-03-07 21:23:35 -05:00
binwiederhier
a2f1a45097 Thank you @rwxd for your sponsorship 2024-03-07 21:22:59 -05:00
binwiederhier
b43fff7c7e Thank you @hermannx5 for your donation 2024-03-07 21:22:02 -05:00
binwiederhier
b186c7a324 Thank you for your donation @rxsantos 2024-03-07 21:21:15 -05:00
binwiederhier
e50b6f4075 Thank you @siebej for your donation 2024-03-07 21:19:21 -05:00
binwiederhier
e5342d5eca Thank you @brookmg for your donation 2024-03-07 21:18:55 -05:00
binwiederhier
a25fc1daaa Thank you @dwain-lab for your donation 2024-03-07 21:18:25 -05:00
binwiederhier
6e75108aa9 Thank you @vovayartsev for your sponsorship 2024-03-07 21:17:40 -05:00
binwiederhier
0735a5cb48 Thank you @FrameXX for your donation 2024-03-07 21:17:13 -05:00
binwiederhier
088aede833 Thank you @mursec for your donation 2024-03-07 21:15:58 -05:00
binwiederhier
2119954f67 Thank you @ajay-actuary for your sponsorship 2024-03-07 21:15:00 -05:00
binwiederhier
220b012ae3 Thank you @CataIana for your donation 2024-03-07 21:11:29 -05:00
binwiederhier
e1278a5e92 Thank you @tomershvueli for your donation 2024-03-07 21:10:46 -05:00
binwiederhier
0ae62d36d2 Thank you @zark0s for your sponsorship 2024-03-07 21:10:00 -05:00
binwiederhier
70729edb2b Thank you @Emiliaaah for your donation 2024-03-07 21:09:14 -05:00
binwiederhier
de2f7d3e9b Remove Lemmy 2024-03-07 21:06:46 -05:00
binwiederhier
8c69234e28 Revert Docker user fix 2024-03-07 16:59:42 -05:00
binwiederhier
a9b7c4530b Derp 2024-03-07 16:34:49 -05:00
binwiederhier
76fc015775 Release notes 2024-03-07 16:30:35 -05:00
binwiederhier
ef302d22a9 Avoid panic if user.Current() fails; add logging to "ntfy subscribe" to help figure out what's wrong 2024-03-07 15:56:20 -05:00
Philipp C. Heckel
7ab8ef73a3 Merge pull request #1044 from tripleee/patch-2
Update Danish translations
2024-03-07 15:07:04 -05:00
Philipp C. Heckel
7616b24e30 Merge pull request #1045 from tripleee/patch-3
Update Finnish translations
2024-03-07 15:06:43 -05:00
Philipp C. Heckel
05c1b264f2 Merge pull request #1046 from tripleee/patch-4
Update Norwegian translations
2024-03-07 15:04:43 -05:00
Philipp C. Heckel
35ccfdb8d8 Merge pull request #1043 from tripleee/patch-1
Update Swedish localizations: typo fixes etc
2024-03-07 15:04:01 -05:00
binwiederhier
d744204052 Release notes 2024-03-07 15:01:24 -05:00
binwiederhier
6c3aca4cd6 Merge branch 'main' into cmj2002/main 2024-03-07 14:54:57 -05:00
binwiederhier
d54db74f5a Release notes 2024-03-07 13:08:56 -05:00
binwiederhier
4e1980c2cc Merge branch 'main' into patch-1 2024-03-07 13:05:47 -05:00
Philipp C. Heckel
40f990fffe Merge pull request #1050 from binwiederhier/message-size-limit+delay-limit
Message size limit+delay limit
2024-03-07 13:04:04 -05:00
binwiederhier
8931f25ac5 Ahh 2024-03-07 13:00:39 -05:00
binwiederhier
94f60fb5b8 Lint 2024-03-07 12:53:39 -05:00
binwiederhier
01b397a31a Release notes 2024-03-07 12:50:01 -05:00
binwiederhier
f2cd1edc57 Add some helper for base-url 2024-03-07 12:44:31 -05:00
binwiederhier
243123fd7e Convert duration flags, add docs 2024-03-07 12:22:35 -05:00
binwiederhier
36b33030f3 Add message-{size|delay}-limit 2024-03-07 11:53:12 -05:00
binwiederhier
17709f2fb7 Merge branch 'main' into zhzy0077-patch-1 2024-03-07 10:38:19 -05:00
binwiederhier
a8c17c1856 Release notes 2024-03-07 10:36:56 -05:00
binwiederhier
8ac920d28c Merge branch 'main' of github.com:binwiederhier/ntfy 2024-03-07 10:36:52 -05:00
binwiederhier
239c620707 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2024-03-07 10:36:40 -05:00
Philipp C. Heckel
f2e600d681 Merge pull request #967 from arahja/add_non-root_user_to_containers
Added non-root user to docker images.
2024-03-07 10:33:32 -05:00
Philipp C. Heckel
6486db99fa Merge pull request #1014 from lennart-m/doc/watchtower-example
Docs: Enhance Watchtower example
2024-03-07 10:24:57 -05:00
binwiederhier
766ca05e15 Release notes 2024-03-07 10:24:19 -05:00
binwiederhier
6c41f69db7 Merge branch 'main' into Tom-Hubrecht/main 2024-03-07 10:22:39 -05:00
binwiederhier
cae696c323 Switch to non-deprecated emoji extension in mkdocs 2024-03-07 10:17:43 -05:00
Philipp C. Heckel
42dc8bc3f5 Merge pull request #1049 from binwiederhier/remove-rate-topics
Remove Rate-Topics
2024-03-07 10:17:25 -05:00
binwiederhier
aecf0a5f25 Remove Rate-Topics 2024-03-07 10:07:42 -05:00
Philipp C. Heckel
896a1b007f Merge pull request #1022 from DerRockWolf/patch-1
docs(config.md): fix UnifiedPush links
2024-03-07 08:37:30 -05:00
Philipp C. Heckel
fc5973751a Merge pull request #965 from ksurl/docs-watchtower-token
docs: add shoutrrr auth token url format
2024-03-07 08:36:25 -05:00
Philipp C. Heckel
9ca5133c76 Merge pull request #1001 from UpcraftLP/patch-1
fix apk output path in docs
2024-03-07 08:35:25 -05:00
tripleee
509f59ac3c Update Norwegian translations
* Avoid compound errors
* Typo fix
2024-03-06 19:57:18 +02:00
tripleee
9d8482a119 Update Finnish translations
* Avoid compound errors
* Typo fixes and wording tweaks
2024-03-06 19:48:13 +02:00
tripleee
34343fae02 Update Danish translations
Avoid compound errors
2024-03-06 19:32:24 +02:00
tripleee
1ff6ecf5d8 Update Swedish localizations: typo fixes etc
- Avoid compound errors
- Small wording tweaks
2024-03-06 19:23:00 +02:00
109247019824
f2f7ad8253 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2024-02-29 13:02:06 +01:00
109247019824
78de5d4866 Translated using Weblate (Bulgarian)
Currently translated at 98.1% (375 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2024-02-27 23:57:45 +01:00
Хусниддин
9449b0b875 Translated using Weblate (Uzbek)
Currently translated at 6.5% (25 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uz/
2024-02-25 00:02:05 +01:00
Хусниддин
b86c50c60a Added translation using Weblate (Uzbek) 2024-02-23 23:28:55 +01:00
DerRockWolf
34f90facb2 docs(config.md): fix UnifiedPush links 2024-02-09 21:13:59 +00:00
Tom Hubrecht
99a0c72d49 Remove dependence on mkdocs-simple-hooks
Since mkdocs v1.4, the hooks are a native feature
2024-02-05 13:29:55 +01:00
arahja
c4d9e397ab Merge branch 'binwiederhier:main' into add_non-root_user_to_containers 2024-02-02 10:44:16 -06:00
Lennart
3f49f51847 docs: enhance watchtower example
document usage of environment variable WATCHTOWER_NOTIFICATION_SKIP_TITLE for watchtower container so the provided title will not be overwritten
2024-01-30 17:01:36 +01:00
binwiederhier
1d2b759dc0 Bump 2024-01-21 11:27:10 -05:00
binwiederhier
8e62d2af6f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2024-01-21 11:24:13 -05:00
YMan84
7dfcda9306 Update publish.md with powershell snippet for uploading files 2024-01-15 19:11:55 -05:00
Up
4cfc64e528 fix docs showing wrong apk path 2024-01-14 10:25:46 +01:00
109247019824
020968961e Translated using Weblate (Bulgarian)
Currently translated at 88.2% (337 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2024-01-05 23:06:59 +00:00
Jonne Saloranta
2cfe18c9c8 Translated using Weblate (Finnish)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fi/
2024-01-02 20:09:12 +00:00
Costin Stroie
39dc90e795 Translated using Weblate (Romanian)
Currently translated at 32.4% (124 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ro/
2023-12-31 11:09:05 +01:00
Costin Stroie
fe4101d26c Translated using Weblate (Romanian)
Currently translated at 28.2% (108 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ro/
2023-12-30 10:09:27 +01:00
tinect
80861d33b4 Translated using Weblate (German)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2023-12-30 00:45:43 +01:00
tinect
1383b41062 Translated using Weblate (German)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2023-12-29 01:09:12 +01:00
Panda
99c03b0e83 Translated using Weblate (Danish)
Currently translated at 100.0% (382 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/da/
2023-12-14 00:06:55 +01:00
Philipp C. Heckel
bb4b5d2bc8 Merge pull request #976 from drizzt/patch-1
Fix typo in it.json
2023-12-10 20:19:41 -05:00
M G
4916806298 Added translation using Weblate (Esperanto) 2023-12-10 19:26:48 +01:00
Timothy Redaelli
f350b81da4 Fix typo in it.json 2023-12-09 01:20:48 +01:00
109247019824
1e306e08c9 Translated using Weblate (Bulgarian)
Currently translated at 88.2% (337 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2023-12-07 06:05:18 +01:00
binwiederhier
518505fa9d Thank you @beekeeb for your generous sponsorship 2023-12-04 13:27:33 -05:00
binwiederhier
1f4a0601f8 Thank you @tka85 for your generous donation! 2023-12-04 13:25:50 -05:00
binwiederhier
0347fdc192 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-12-04 13:25:07 -05:00
Piotr Icikowski
d207f3ca26 Translated using Weblate (Polish)
Currently translated at 91.8% (351 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pl/
2023-12-03 23:08:22 +01:00
Adam Rahja
f2d6f09671 Added non-root user to docker images. This gives you the ability to run ntfy as a non-root user. resolves #966 2023-11-30 08:08:52 -06:00
ksurl
0f44d20da5 clean up 2023-11-30 00:21:11 +00:00
ksurl
aaf53d5d3f add auth token url format 2023-11-30 00:18:24 +00:00
binwiederhier
154e3945c9 Release notes 2023-11-27 21:56:22 -05:00
binwiederhier
0a82729650 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-11-27 21:56:06 -05:00
Philipp C. Heckel
5876c923a2 Merge pull request #964 from wunter8/patch-5
Update docker compose example
2023-11-27 09:01:24 -05:00
wunter8
a2b56154a7 Update docker compose example
Noticed that this wasn't consistent with the other paths
2023-11-27 06:33:33 -07:00
Cao Mingjun
4b1468cfd8 Web: support pasting images to PublishDialog 2023-11-25 07:39:20 +00:00
Cao Mingjun
00fe639a95 Remove debug logging 2023-11-25 05:22:02 +00:00
Cao Mingjun
94781c89f3 Preview local file before send 2023-11-25 03:39:26 +00:00
Philipp C. Heckel
6e2f9a5bdd Merge pull request #961 from cmj2002/main
Update docker-dev go version
2023-11-24 15:33:47 -05:00
Cao Mingjun
a3312f69fb Web: support pasting images from clipboard 2023-11-24 14:49:56 +00:00
Cao Mingjun
87bcf59bd4 Update docker-dev go version 2023-11-24 14:00:16 +00:00
Caio Facchinato
4817e33942 Translated using Weblate (Portuguese (Brazil))
Currently translated at 74.3% (284 of 382 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2023-11-24 06:02:05 +01:00
binwiederhier
141cbad5ad Thank you @ubipo for your sponsorship 2023-11-22 07:19:51 -05:00
zhzy0077
6a10bac017 Update template server.yml 2023-09-08 13:21:55 +08:00
zhzy0077
f565302a0f Fix typo 2023-08-21 11:56:31 +08:00
zhzy0077
6a3f169a47 fix typo. 2023-08-21 11:55:20 +08:00
zhzy0077
6bd8875375 fix typo and update docs 2023-08-21 11:51:48 +08:00
Zhiyuan Zheng
3691e59af1 Expose MessageLimit as a configuration 2023-08-11 13:16:53 +08:00
70 changed files with 4580 additions and 2930 deletions

View File

@@ -1,30 +1,24 @@
name: build
on: [push, pull_request]
on: [ push, pull_request ]
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
- name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.21.x'
-
name: Install node
go-version: '1.22.x'
- name: Install node
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
-
name: Install dependencies
- name: Install dependencies
run: make build-deps-ubuntu
-
name: Build all the things
- name: Build all the things
run: make build
-
name: Print build results and checksums
- name: Print build results and checksums
run: make cli-build-results

View File

@@ -7,35 +7,28 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
- name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.21.x'
-
name: Install node
go-version: '1.22.x'
- name: Install node
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
-
name: Docker login
- name: Docker login
uses: docker/login-action@v2
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
-
name: Install dependencies
- name: Install dependencies
run: make build-deps-ubuntu
-
name: Build and publish
- name: Build and publish
run: make release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
name: Print build results and checksums
- name: Print build results and checksums
run: make cli-build-results

View File

@@ -1,39 +1,30 @@
name: test
on: [push, pull_request]
on: [ push, pull_request ]
jobs:
test:
runs-on: ubuntu-latest
steps:
-
name: Checkout code
- name: Checkout code
uses: actions/checkout@v3
-
name: Install Go
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.21.x'
-
name: Install node
go-version: '1.22.x'
- name: Install node
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
-
name: Install dependencies
- name: Install dependencies
run: make build-deps-ubuntu
-
name: Build docs (required for tests)
- name: Build docs (required for tests)
run: make docs
-
name: Build web app (required for tests)
- name: Build web app (required for tests)
run: make web
-
name: Run tests, formatting, vetting and linting
- name: Run tests, formatting, vetting and linting
run: make check
-
name: Run coverage
- name: Run coverage
run: make coverage
-
name: Upload coverage to codecov.io
- name: Upload coverage to codecov.io
run: make coverage-upload

View File

@@ -1,4 +1,4 @@
FROM golang:1.20-bullseye as builder
FROM golang:1.22-bullseye as builder
ARG VERSION=dev
ARG COMMIT=unknown

View File

@@ -9,7 +9,6 @@
[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w)
[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org)
[![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org)
[![Lemmy](https://img.shields.io/badge/Lemmy-discuss-green)](https://discuss.ntfy.sh/c/ntfy)
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
@@ -50,7 +49,6 @@ works best for you:
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
* [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_)
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
## Announcements/beta testers
@@ -71,7 +69,7 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
## Sponsors
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks who have sponsored ntfy in the past, or are still sponsoring ntfy:
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
@@ -165,6 +163,32 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
<a href="https://github.com/pgwiebes"><img src="https://github.com/pgwiebes.png" width="40px" /></a>
<a href="https://github.com/ralhei"><img src="https://github.com/ralhei.png" width="40px" /></a>
<a href="https://github.com/TechMDW"><img src="https://github.com/TechMDW.png" width="40px" /></a>
<a href="https://github.com/ubipo"><img src="https://github.com/ubipo.png" width="40px" /></a>
<a href="https://github.com/tka85"><img src="https://github.com/tka85.png" width="40px" /></a>
<a href="https://github.com/beekeeb"><img src="https://github.com/beekeeb.png" width="40px" /></a>
<a href="https://github.com/Emiliaaah"><img src="https://github.com/Emiliaaah.png" width="40px" /></a>
<a href="https://github.com/zark0s"><img src="https://github.com/zark0s.png" width="40px" /></a>
<a href="https://github.com/tomershvueli"><img src="https://github.com/tomershvueli.png" width="40px" /></a>
<a href="https://github.com/CataIana"><img src="https://github.com/CataIana.png" width="40px" /></a>
<a href="https://github.com/ajay-actuary"><img src="https://github.com/ajay-actuary.png" width="40px" /></a>
<a href="https://github.com/mursec"><img src="https://github.com/mursec.png" width="40px" /></a>
<a href="https://github.com/FrameXX"><img src="https://github.com/FrameXX.png" width="40px" /></a>
<a href="https://github.com/vovayartsev"><img src="https://github.com/vovayartsev.png" width="40px" /></a>
<a href="https://github.com/dwain-lab"><img src="https://github.com/dwain-lab.png" width="40px" /></a>
<a href="https://github.com/brookmg"><img src="https://github.com/brookmg.png" width="40px" /></a>
<a href="https://github.com/siebej"><img src="https://github.com/siebej.png" width="40px" /></a>
<a href="https://github.com/rxsantos"><img src="https://github.com/rxsantos.png" width="40px" /></a>
<a href="https://github.com/hermannx5"><img src="https://github.com/hermannx5.png" width="40px" /></a>
<a href="https://github.com/rwxd"><img src="https://github.com/rwxd.png" width="40px" /></a>
<a href="https://github.com/Integral-Tech"><img src="https://github.com/Integral-Tech.png" width="40px" /></a>
<a href="https://github.com/TheTomik1"><img src="https://github.com/TheTomik1.png" width="40px" /></a>
<a href="https://github.com/dav23r"><img src="https://github.com/dav23r.png" width="40px" /></a>
<a href="https://github.com/stannynuytkens"><img src="https://github.com/stannynuytkens.png" width="40px" /></a>
<a href="https://github.com/danbartram"><img src="https://github.com/danbartram.png" width="40px" /></a>
<a href="https://github.com/arthurgleckler"><img src="https://github.com/arthurgleckler.png" width="40px" /></a>
<a href="https://github.com/tomroth04"><img src="https://github.com/tomroth04.png" width="40px" /></a>
<a href="https://github.com/Circenn5130"><img src="https://github.com/Circenn5130.png" width="40px" /></a>
<a href="https://github.com/jceloria"><img src="https://github.com/jceloria.png" width="40px" /></a>
I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/),
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:

View File

@@ -2,6 +2,7 @@ package client
import (
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/log"
"os"
)
@@ -44,6 +45,7 @@ func NewConfig() *Config {
// LoadConfig loads the Client config from a yaml file
func LoadConfig(filename string) (*Config, error) {
log.Debug("Loading client config from %s", filename)
b, err := os.ReadFile(filename)
if err != nil {
return nil, err

View File

@@ -6,23 +6,22 @@ import (
"errors"
"fmt"
"github.com/stripe/stripe-go/v74"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"io/fs"
"math"
"net"
"net/netip"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"heckel.io/ntfy/v2/log"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/util"
)
func init() {
@@ -35,7 +34,7 @@ const (
var flagsServe = append(
append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
@@ -45,19 +44,19 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
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)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Value: util.FormatDuration(server.DefaultCacheBatchTimeout), Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
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-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
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: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
@@ -76,16 +75,18 @@ 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: "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"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
@@ -126,7 +127,7 @@ func execServe(c *cli.Context) error {
// Read all the options
config := c.String("config")
baseURL := c.String("base-url")
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
listenHTTP := c.String("listen-http")
listenHTTPS := c.String("listen-https")
listenUnix := c.String("listen-unix")
@@ -140,19 +141,19 @@ func execServe(c *cli.Context) error {
webPushEmailAddress := c.String("web-push-email-address")
webPushStartupQueries := c.String("web-push-startup-queries")
cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
cacheDurationStr := c.String("cache-duration")
cacheStartupQueries := c.String("cache-startup-queries")
cacheBatchSize := c.Int("cache-batch-size")
cacheBatchTimeout := c.Duration("cache-batch-timeout")
cacheBatchTimeoutStr := c.String("cache-batch-timeout")
authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access")
attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-interval")
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
keepaliveIntervalStr := c.String("keepalive-interval")
managerIntervalStr := c.String("manager-interval")
disallowedTopics := c.StringSlice("disallowed-topics")
webRoot := c.String("web-root")
enableSignup := c.Bool("enable-signup")
@@ -171,17 +172,19 @@ func execServe(c *cli.Context) error {
twilioAuthToken := c.String("twilio-auth-token")
twilioPhoneNumber := c.String("twilio-phone-number")
twilioVerifyService := c.String("twilio-verify-service")
messageSizeLimitStr := c.String("message-size-limit")
messageDelayLimitStr := c.String("message-delay-limit")
totalTopicLimit := c.Int("global-topic-limit")
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
visitorRequestLimitReplenishStr := c.String("visitor-request-limit-replenish")
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
behindProxy := c.Bool("behind-proxy")
stripeSecretKey := c.String("stripe-secret-key")
stripeWebhookKey := c.String("stripe-webhook-key")
@@ -190,6 +193,64 @@ func execServe(c *cli.Context) error {
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
profileListenHTTP := c.String("profile-listen-http")
// Convert durations
cacheDuration, err := util.ParseDuration(cacheDurationStr)
if err != nil {
return fmt.Errorf("invalid cache duration: %s", cacheDurationStr)
}
cacheBatchTimeout, err := util.ParseDuration(cacheBatchTimeoutStr)
if err != nil {
return fmt.Errorf("invalid cache batch timeout: %s", cacheBatchTimeoutStr)
}
attachmentExpiryDuration, err := util.ParseDuration(attachmentExpiryDurationStr)
if err != nil {
return fmt.Errorf("invalid attachment expiry duration: %s", attachmentExpiryDurationStr)
}
keepaliveInterval, err := util.ParseDuration(keepaliveIntervalStr)
if err != nil {
return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr)
}
managerInterval, err := util.ParseDuration(managerIntervalStr)
if err != nil {
return fmt.Errorf("invalid manager interval: %s", managerIntervalStr)
}
messageDelayLimit, err := util.ParseDuration(messageDelayLimitStr)
if err != nil {
return fmt.Errorf("invalid message delay limit: %s", messageDelayLimitStr)
}
visitorRequestLimitReplenish, err := util.ParseDuration(visitorRequestLimitReplenishStr)
if err != nil {
return fmt.Errorf("invalid visitor request limit replenish: %s", visitorRequestLimitReplenishStr)
}
visitorEmailLimitReplenish, err := util.ParseDuration(visitorEmailLimitReplenishStr)
if err != nil {
return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
}
// Convert sizes to bytes
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
if err != nil {
return fmt.Errorf("invalid message size limit: %s", messageSizeLimitStr)
}
attachmentTotalSizeLimit, err := util.ParseSize(attachmentTotalSizeLimitStr)
if err != nil {
return fmt.Errorf("invalid attachment total size limit: %s", attachmentTotalSizeLimitStr)
}
attachmentFileSizeLimit, err := util.ParseSize(attachmentFileSizeLimitStr)
if err != nil {
return fmt.Errorf("invalid attachment file size limit: %s", attachmentFileSizeLimitStr)
}
visitorAttachmentTotalSizeLimit, err := util.ParseSize(visitorAttachmentTotalSizeLimitStr)
if err != nil {
return fmt.Errorf("invalid visitor attachment total size limit: %s", visitorAttachmentTotalSizeLimitStr)
}
visitorAttachmentDailyBandwidthLimit, err := util.ParseSize(visitorAttachmentDailyBandwidthLimitStr)
if err != nil {
return fmt.Errorf("invalid visitor attachment daily bandwidth limit: %s", visitorAttachmentDailyBandwidthLimitStr)
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
}
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
@@ -213,10 +274,15 @@ func execServe(c *cli.Context) error {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
return errors.New("if attachment-cache-dir is set, base-url must also be set")
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return errors.New("if set, base-url must start with http:// or https://")
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
return errors.New("if set, base-url must not end with a slash (/)")
} else if baseURL != "" {
u, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("if set, base-url must be a valid URL, e.g. https://ntfy.mydomain.com: %v", err)
} else if u.Scheme != "http" && u.Scheme != "https" {
return errors.New("if set, base-url must be a valid URL starting with http:// or https://, e.g. https://ntfy.mydomain.com")
} else if u.Path != "" {
return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path)
}
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
return errors.New("if set, upstream-base-url must start with http:// or https://")
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
@@ -233,6 +299,11 @@ func execServe(c *cli.Context) error {
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 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 {
return errors.New("message-size-limit cannot be higher than 5M")
}
}
// Backwards compatibility
@@ -257,26 +328,6 @@ func execServe(c *cli.Context) error {
listenHTTP = ""
}
// Convert sizes to bytes
attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit)
if err != nil {
return err
}
attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit)
if err != nil {
return err
}
visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit)
if err != nil {
return err
}
visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
if err != nil {
return err
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
}
// Resolve hosts
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
for _, host := range visitorRequestLimitExemptHosts {
@@ -337,6 +388,8 @@ func execServe(c *cli.Context) error {
conf.TwilioAuthToken = twilioAuthToken
conf.TwilioPhoneNumber = twilioPhoneNumber
conf.TwilioVerifyService = twilioVerifyService
conf.MessageSizeLimit = int(messageSizeLimit)
conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
@@ -379,17 +432,6 @@ func execServe(c *cli.Context) error {
return nil
}
func parseSize(s string, defaultValue int64) (v int64, err error) {
if s == "" {
return defaultValue, nil
}
v, err = util.ParseSize(s)
if err != nil {
return 0, err
}
return v, nil
}
func sigHandlerConfigReload(config string) {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGHUP)

View File

@@ -310,28 +310,43 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
if filename != "" {
return client.LoadConfig(filename)
}
configFile := defaultClientConfigFile()
if s, _ := os.Stat(configFile); s != nil {
return client.LoadConfig(configFile)
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)
}
log.Debug("Config file %s not found", configFile)
}
log.Debug("Loading default config")
return client.NewConfig(), nil
}
//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileUnix() string {
u, _ := user.Current()
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, _ := os.UserConfigDir()
return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
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
return configFile, nil
}
//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileWindows() string {
homeDir, _ := os.UserConfigDir()
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
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 {

View File

@@ -11,6 +11,6 @@ var (
scriptLauncher = []string{"sh", "-c"}
)
func defaultClientConfigFile() string {
func defaultClientConfigFile() (string, error) {
return defaultClientConfigFileUnix()
}

View File

@@ -13,6 +13,6 @@ var (
scriptLauncher = []string{"sh", "-c"}
)
func defaultClientConfigFile() string {
func defaultClientConfigFile() (string, error) {
return defaultClientConfigFileUnix()
}

View File

@@ -10,6 +10,6 @@ var (
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
)
func defaultClientConfigFile() string {
func defaultClientConfigFile() (string, error) {
return defaultClientConfigFileWindows()
}

View File

@@ -366,9 +366,9 @@ func printTier(c *cli.Context, tier *user.Tier) {
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
}

View File

@@ -116,7 +116,7 @@ using Docker Compose (i.e. `docker-compose.yml`):
NTFY_UPSTREAM_BASE_URL: https://ntfy.sh
NTFY_WEB_PUSH_PUBLIC_KEY: <public_key>
NTFY_WEB_PUSH_PRIVATE_KEY: <private_key>
NTFY_WEB_PUSH_FILE: /etc/ntfy/webpush.db
NTFY_WEB_PUSH_FILE: /var/lib/ntfy/webpush.db
NTFY_WEB_PUSH_EMAIL_ADDRESS: <email>
volumes:
- ./:/var/lib/ntfy
@@ -404,10 +404,10 @@ with the given username/password. Be sure to use HTTPS to avoid eavesdropping an
```
### Example: UnifiedPush
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …)
has anonymous write access to the [topic](https://unifiedpush.org/spec/definitions/#endpoint) used for push messages.
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/developers/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …)
has anonymous write access to the [topic](https://unifiedpush.org/developers/spec/definitions/#endpoint) used for push messages.
The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the
**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users)** for more details.
**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users-acl)** for more details.
To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either
allow anonymous write access for the entire prefix or explicitly per topic:
@@ -995,6 +995,15 @@ are the easiest), and then configure the following options:
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.
## Message limits
There are a few message limits that you can configure:
* `message-size-limit` defines the max size of a message body. Please note message sizes >4K are **not recommended,
and largely untested**. The Android/iOS and other clients may not work, or work properly. If FCM and/or APNS is used,
the limit should stay 4K, because their limits are around that size. If you increase this size limit regardless,
FCM and APNS will NOT work for large messages.
* `message-delay-limit` defines the max delay of a message when using the "Delay" header and [scheduled delivery](publish.md#scheduled-delivery).
## Rate limiting
!!! info
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
@@ -1078,20 +1087,23 @@ By default, ntfy puts almost all rate limits on the message publisher, e.g. numb
size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits
of a topic's subscriber, instead of the limits of the publisher.**
If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
publishers (e.g. Matrix/Mastodon servers) are allowed to send.
If subscriber-based rate limiting is enabled, **messages published on UnifiedPush topics** (topics starting with `up`, e.g. `up123456789012`)
will be counted towards the "rate visitor" of the topic. A "rate visitor" is the first subscriber to the topic.
Once enabled, a client may send a `Rate-Topics: <topic1>,<topic2>,...` header when subscribing to topics via
HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic.
Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as
a "rate visitor", i.e. the visitor whose rate limits will be used when publishing on this topic. Note that setting the rate visitor
requires **read-write permission** on the topic.
UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage`
If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage`
response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's
`visitor-message-daily-limit`.
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
!!! info
Due to a [denial-of-service issue](https://github.com/binwiederhier/ntfy/issues/1048), support for the `Rate-Topics`
header was removed entirely. This is unfortunate, but subscriber-based rate limiting will still work for `up*` topics.
## Tuning for scale
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
@@ -1388,6 +1400,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. |
| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header |
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
@@ -1414,7 +1428,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup |
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
The format for a *duration* is: `<number>(smhd)`, e.g. 30s, 20m, 1h or 3d.
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
## Command line options
@@ -1446,7 +1460,7 @@ OPTIONS:
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--config value, -c value config file (default: "/etc/ntfy/server.yml") [$NTFY_CONFIG_FILE]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
@@ -1456,19 +1470,19 @@ OPTIONS:
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: "12h") [$NTFY_CACHE_DURATION]
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: "0s") [$NTFY_CACHE_BATCH_TIMEOUT]
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: "3h") [$NTFY_ATTACHMENT_EXPIRY_DURATION]
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: "45s") [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: "1m") [$NTFY_MANAGER_INTERVAL]
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
--web-root value, --web_root value sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT]
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
@@ -1487,16 +1501,18 @@ OPTIONS:
--twilio-auth-token value, --twilio_auth_token value Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]
--twilio-phone-number value, --twilio_phone_number value Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]
--twilio-verify-service value, --twilio_verify_service value Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]
--message-size-limit value, --message_size_limit value size limit for the message (see docs for limitations) (default: "4K") [$NTFY_MESSAGE_SIZE_LIMIT]
--message-delay-limit value, --message_delay_limit value max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT]
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: "5s") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
@@ -1509,6 +1525,6 @@ OPTIONS:
--web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
--web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]
--web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
--web-push-startup-queries value, --web_push_startup-queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
--web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
--help, -h show help
```

View File

@@ -1,4 +1,4 @@
# Deprecation notices
# Deprecations and breaking changes
This page is used to list deprecation notices for ntfy. Deprecated commands and options will be
**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated
before the behavior is changed depends on the severity of the change, and how prominent the feature is.

View File

@@ -363,7 +363,7 @@ To build your own version with Firebase, you must:
* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
* Then run:
```
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
# To build an unsigned .apk (app/build/outputs/apk/play/release/*.apk)
./gradlew assemblePlayRelease
# To build a bundle .aab (app/play/release/*.aab)

View File

@@ -162,14 +162,23 @@ services:
image: containrrr/watchtower
environment:
- WATCHTOWER_NOTIFICATIONS=shoutrrr
- WATCHTOWER_NOTIFICATION_SKIP_TITLE=True
- WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
```
The environment variable `WATCHTOWER_NOTIFICATION_SKIP_TITLE` is required to prevent Watchtower from [replacing the `title` query parameter](https://containrrr.dev/watchtower/notifications/#settings). If omitted, the provided notification title will not be used.
Or, if you only want to send notifications using shoutrrr:
```
shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
```
Authentication tokens are also supported via the generic webhook and authorization header using this url format (replace the domain, topic and token with your own):
```
generic+https://DOMAIN/TOPIC?@authorization=Bearer+TOKEN`
```
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->
@@ -598,6 +607,8 @@ This will only work on selfhosted [traccar](https://www.traccar.org/) ([Github](
The easiest way to integrate traccar with ntfy, is to configure ntfy as the SMS provider for your instance. You then can set your ntfy topic as your account's phone number in traccar. Sending the email notifications to ntfy will not work, as ntfy does not support HTML emails.
**Info:** Add a phone number to your traccar account not in device, as otherwise it will not try to send SMS.
**Caution:** JSON publishing is only possible, when POST-ing to the root URL of the ntfy instance. (see [documentation](publish.md#publish-as-json))
```xml
<entry key='sms.http.url'>https://ntfy.sh</entry>

View File

@@ -1,6 +1,7 @@
import os
import shutil
def copy_fonts(config, **kwargs):
site_dir = config['site_dir']
shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get'))
def on_post_build(config, **kwargs):
site_dir = config["site_dir"]
shutil.copytree("docs/static/fonts", os.path.join(site_dir, "get"))

View File

@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.tar.gz
tar zxvf ntfy_2.8.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.8.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.tar.gz
tar zxvf ntfy_2.10.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.10.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.tar.gz
tar zxvf ntfy_2.8.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.8.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv6.tar.gz
tar zxvf ntfy_2.10.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.10.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.tar.gz
tar zxvf ntfy_2.8.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.8.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.tar.gz
tar zxvf ntfy_2.10.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.10.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.tar.gz
tar zxvf ntfy_2.8.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.8.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.tar.gz
tar zxvf ntfy_2.10.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.10.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -110,7 +110,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -118,7 +118,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -126,7 +126,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -134,7 +134,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -144,28 +144,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.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.8.0/ntfy_2.8.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.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.8.0/ntfy_2.8.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.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.8.0/ntfy_2.8.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.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.8.0/ntfy_2.8.0_darwin_all.tar.gz > ntfy_2.8.0_darwin_all.tar.gz
tar zxvf ntfy_2.8.0_darwin_all.tar.gz
sudo cp -a ntfy_2.8.0_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_darwin_all.tar.gz > ntfy_2.10.0_darwin_all.tar.gz
tar zxvf ntfy_2.10.0_darwin_all.tar.gz
sudo cp -a ntfy_2.10.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.8.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.10.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -224,7 +224,7 @@ 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.8.0/ntfy_2.8.0_windows_amd64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).

View File

@@ -6,6 +6,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
## Official integrations
- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification
- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs
- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push notifications that work with just about every platform
- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool
@@ -16,7 +17,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.8/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring
- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool
- [Scrt.link](https://scrt.link/) - Share a secret
@@ -24,7 +25,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring
- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification
- [HetrixTools](https://docs.hetrixtools.com/ntfy-sh-notifications/) - Uptime monitoring
## Integration via HTTP/SMTP/etc.
@@ -138,9 +139,24 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [jetspotter](https://github.com/vvanouytsel/jetspotter) - send notifications when planes are spotted near you (Go)
- [monitoring_ntfy](https://www.drupal.org/project/monitoring_ntfy) - Drupal monitoring Ntfy.sh integration (PHP/Drupal)
- [Notify](https://flathub.org/apps/com.ranfdev.Notify) - Native GTK4 client for ntfy (Rust)
- [notify-via-ntfy](https://exchange.checkmk.com/p/notify-via-ntfy) - Checkmk plugin to send notifications via ntfy (Python)
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
## Blog + forum posts
- [ntfy / Emacs Lisp](https://speechcode.com/blog/ntfy/) - speechcode.com - 3/2024
- [Boost Your Productivity with ntfy.sh: The Ultimate Notification Tool for Command-Line Users](https://dev.to/archetypal/boost-your-productivity-with-ntfysh-the-ultimate-notification-tool-for-command-line-users-iil) - dev.to - 3/2024
- [Nextcloud Talk (F-Droid version) notifications using ntfy (ntfy.sh)](https://www.youtube.com/watch?v=0a6PpfN5PD8) - youtube.com - 2/2024
- [ZFS and SMART Warnings via Ntfy](https://rair.dev/zfs-smart-ntfy/) - rair.dev - 2/2024
- [Automating Security Camera Notifications With Home Assistant and Ntfy](https://runtimeterror.dev/automating-camera-notifications-home-assistant-ntfy/) ⭐ - runtimeterror.dev - 2/2024
- [Ntfy: self-hosted notification service](https://medium.com/@williamdonze/ntfy-self-hosted-notification-service-0f3eada6e657) ⭐ - williamdonze.medium.com - 1/2024
- [Lets Supercharge Snowflake Alerts with Cool ntfy Open-source Notifications!](https://sarathi-data-ml-cloud.medium.com/lets-supercharge-snowflake-alerts-with-cool-ntfy-open-source-notifications-296da442c331) - sarathi-data-ml-cloud.medium.com - 1/2024
- [Setting up NTFY with Ngnix-Proxy-Manager, authentication and Ansible notifications](https://random-it-blog.de/rocky-linux/setting-up-ntfy-with-ngnix-proxy-manager-authentication-and-ansible-notifications/) - random-it-blog.de - 12/2023
- [Introducing the Monitoring Ntfy.sh Integration Module: Real-time Notifications for Drupal Monitoring](https://cyberschorsch.dev/drupal/introducing-monitoring-ntfysh-integration-module-real-time-notifications-drupal-monitoring) - cyberschorsch.dev - 11/2023
- [How to install Ntfy.sh on CasaOS using BigBearCasaOS](https://www.youtube.com/watch?v=wSWhtSNwTd8) - youtube.com - 10/2023
- [Podman Update Notifications via Ntfy](https://rair.dev/podman-update-notifications-ntfy/) - rair.dev - 9/2023
- [Easy Push Notifications With ntfy.sh](https://runtimeterror.dev/easy-push-notifications-with-ntfy/) ⭐ - runtimeterror.dev - 9/2023
- [Ntfy: Your Ultimate Push Notification Powerhouse!](https://kkamalesh117.medium.com/ntfy-your-ultimate-push-notification-powerhouse-1968c070f1d1) - kkamalesh117.medium.com - 9/2023
- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023
- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023
- [Why NTFY is the Ultimate Push Notification Tool for Your Needs](https://osintph.medium.com/why-ntfy-is-the-ultimate-push-notification-tool-for-your-needs-e767421c84c5) - osintph.medium.com - 9/2023
@@ -226,7 +242,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021
## Alternative ntfy servers
Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,66 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
### ntfy server v2.8.0
### ntfy server v2.10.0
Released Mar 27, 2024
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
title template that will be filled with values from a JSON body (e.g. `curl -gd '{"alert":"Disk space low"}' "ntfy.sh/mytopic?tpl=1&m={{.alert}}"`).
This is great for services that let you specify a webhook URL but do not let you change the webhook body (such as GitHub, or Grafana).
**Features:**
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
### ntfy server v2.9.0
Released Mar 7, 2024
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
message delays** in scheduled delivery messages. The web app also now supports pasting images from the clipboard. Other
than that, only a few bug fixes and documentation updates, and a teeny tiny breaking change 😬.
!!! info
⚠️ **Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects
installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used.
Normally I'd never remove a feature, but this is a security issue, and likely affects almost nobody.
**Features:**
* Support for larger message delays with `message-delay-limit` (see [message limits](config.md#message-limits), [#1050](https://github.com/binwiederhier/ntfy/pull/1050)/[#1019](https://github.com/binwiederhier/ntfy/issues/1019), thanks to [@MrChadMWood](https://github.com/MrChadMWood) for reporting)
* Support for larger message body sizes with `message-size-limit` (use at your own risk, see [message limits](config.md#message-limits), [#836](https://github.com/binwiederhier/ntfy/pull/836)/[#1050](https://github.com/binwiederhier/ntfy/pull/1050), thanks to [@zhzy0077](https://github.com/zhzy0077) for implementing this, and to [@nkjshlsqja7331](https://github.com/nkjshlsqja7331) for reporting)
* Web app: You can now paste images into the message bar or publish dialog ([#963](https://github.com/binwiederhier/ntfy/pull/963)/[#572](https://github.com/binwiederhier/ntfy/issues/572), thanks to [@cmj2002](https://github.com/cmj2002) for implementing, and [@rounakdatta](https://github.com/rounakdatta) for reporting)
**Bug fixes + maintenance:**
* ⚠️ Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))
**Documentation:**
* Remove `mkdocs-simple-hooks` ([#1016](https://github.com/binwiederhier/ntfy/pull/1016), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht))
* Update Watchtower example ([#1014](https://github.com/binwiederhier/ntfy/pull/1014), thanks to [@lennart-m](https://github.com/lennart-m))
* Fix dead links ([#1022](https://github.com/binwiederhier/ntfy/pull/1022), thanks to [@DerRockWolf](https://github.com/DerRockWolf))
* PowerShell file upload example ([#1004](https://github.com/binwiederhier/ntfy/pull/1004), thanks to [@YMan84](https://github.com/YMan84))
## ntfy iOS app v1.3
Released Nov 26, 2023
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well
as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs
for a long time, and I hope that they are finally fixed.
Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.
**Bug fixes:**
* UI not updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)/[#402](https://github.com/binwiederhier/ntfy/issues/402), thanks to [@tcaputi](https://github.com/tcaputi))
## ntfy server v2.8.0
Released November 19, 2023
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the `Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally), web app crash fixes
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes
for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the
`Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally),
web app crash fixes
**Bug fixes + maintenance:**

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,99 +1,103 @@
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
const savedCodeTab = localStorage.getItem('savedTab')
const codeTabs = document.querySelectorAll(".tabbed-set > input")
const savedCodeTab = localStorage.getItem("savedTab");
const codeTabs = document.querySelectorAll(".tabbed-set > input");
for (const tab of codeTabs) {
tab.addEventListener("click", () => {
const current = document.querySelector(`label[for=${tab.id}]`)
const pos = current.getBoundingClientRect().top
const labelContent = current.innerHTML
const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label')
for (const label of labels) {
if (label.innerHTML === labelContent) {
document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true
}
}
// Preserve scroll position
const delta = (current.getBoundingClientRect().top) - pos
window.scrollBy(0, delta)
// Save
localStorage.setItem('savedTab', labelContent)
})
// Select saved tab
const current = document.querySelector(`label[for=${tab.id}]`)
const labelContent = current.innerHTML
if (savedCodeTab === labelContent) {
tab.checked = true
tab.addEventListener("click", () => {
const current = document.querySelector(`label[for=${tab.id}]`);
const pos = current.getBoundingClientRect().top;
const labelContent = current.innerHTML;
const labels = document.querySelectorAll(".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label");
for (const label of labels) {
if (label.innerHTML === labelContent) {
document.querySelector(`input[id=${label.getAttribute("for")}]`).checked = true;
}
}
// Preserve scroll position
const delta = (current.getBoundingClientRect().top) - pos;
window.scrollBy(0, delta);
// Save
localStorage.setItem("savedTab", labelContent);
});
// Select saved tab
const current = document.querySelector(`label[for=${tab.id}]`);
const labelContent = current.innerHTML;
if (savedCodeTab === labelContent) {
tab.checked = true;
}
}
// Lightbox for screenshot
const lightbox = document.createElement('div');
lightbox.classList.add('lightbox');
const lightbox = document.createElement("div");
lightbox.classList.add("lightbox");
document.body.appendChild(lightbox);
const showScreenshotOverlay = (e, el, group, index) => {
lightbox.classList.add('show');
document.addEventListener('keydown', nextScreenshotKeyboardListener);
return showScreenshot(e, group, index);
lightbox.classList.add("show");
document.addEventListener("keydown", nextScreenshotKeyboardListener);
return showScreenshot(e, group, index);
};
const showScreenshot = (e, group, index) => {
const actualIndex = resolveScreenshotIndex(group, index);
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML;
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); };
currentScreenshotGroup = group;
currentScreenshotIndex = actualIndex;
e.stopPropagation();
return false;
const actualIndex = resolveScreenshotIndex(group, index);
lightbox.innerHTML = "<div class=\"close-lightbox\"></div>" + screenshots[group][actualIndex].innerHTML;
lightbox.querySelector("img").onclick = (e) => {
return showScreenshot(e, group, actualIndex + 1);
};
currentScreenshotGroup = group;
currentScreenshotIndex = actualIndex;
e.stopPropagation();
return false;
};
const nextScreenshot = (e) => {
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1);
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1);
};
const previousScreenshot = (e) => {
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1);
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1);
};
const resolveScreenshotIndex = (group, index) => {
if (index < 0) {
return screenshots[group].length - 1;
} else if (index > screenshots[group].length - 1) {
return 0;
}
return index;
if (index < 0) {
return screenshots[group].length - 1;
} else if (index > screenshots[group].length - 1) {
return 0;
}
return index;
};
const hideScreenshotOverlay = (e) => {
lightbox.classList.remove('show');
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
lightbox.classList.remove("show");
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
};
const nextScreenshotKeyboardListener = (e) => {
switch (e.keyCode) {
case 37:
previousScreenshot(e);
break;
case 39:
nextScreenshot(e);
break;
}
switch (e.keyCode) {
case 37:
previousScreenshot(e);
break;
case 39:
nextScreenshot(e);
break;
}
};
let currentScreenshotGroup = '';
let currentScreenshotGroup = "";
let currentScreenshotIndex = 0;
let screenshots = {};
Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => {
const group = sg.id;
screenshots[group] = [...sg.querySelectorAll('a')];
screenshots[group].forEach((el, index) => {
el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); };
});
Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => {
const group = sg.id;
screenshots[group] = [...sg.querySelectorAll("a")];
screenshots[group].forEach((el, index) => {
el.onclick = (e) => {
return showScreenshotOverlay(e, el, group, index);
};
});
});
lightbox.onclick = hideScreenshotOverlay;

85
go.mod
View File

@@ -5,23 +5,23 @@ go 1.21
toolchain go1.21.3
require (
cloud.google.com/go/firestore v1.14.0 // indirect
cloud.google.com/go/storage v1.35.1 // indirect
cloud.google.com/go/firestore v1.15.0 // indirect
cloud.google.com/go/storage v1.40.0 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.3
github.com/gorilla/websocket v1.5.1
github.com/mattn/go-sqlite3 v1.14.18
github.com/mattn/go-sqlite3 v1.14.22
github.com/olebedev/when v1.0.0
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7
golang.org/x/crypto v0.15.0
golang.org/x/oauth2 v0.14.0 // indirect
golang.org/x/sync v0.5.0
golang.org/x/term v0.14.0
golang.org/x/time v0.4.0
google.golang.org/api v0.151.0
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.1
golang.org/x/crypto v0.22.0
golang.org/x/oauth2 v0.19.0 // indirect
golang.org/x/sync v0.7.0
golang.org/x/term v0.19.0
golang.org/x/time v0.5.0
google.golang.org/api v0.176.1
gopkg.in/yaml.v2 v2.4.0
)
@@ -30,55 +30,62 @@ 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.12.1
firebase.google.com/go/v4 v4.14.0
github.com/SherClockHolmes/webpush-go v1.3.0
github.com/lib/pq v1.10.9
github.com/microcosm-cc/bluemonday v1.0.26
github.com/prometheus/client_golang v1.17.0
github.com/prometheus/client_golang v1.19.0
github.com/stripe/stripe-go/v74 v74.30.0
)
require (
cloud.google.com/go v0.110.10 // indirect
cloud.google.com/go/compute v1.23.3 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.1.5 // indirect
cloud.google.com/go/longrunning v0.5.4 // indirect
cloud.google.com/go v0.112.2 // indirect
cloud.google.com/go/auth v0.3.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
cloud.google.com/go/iam v1.1.7 // indirect
cloud.google.com/go/longrunning v0.5.6 // indirect
github.com/AlekSi/pointer v1.2.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.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/googleapis/gax-go/v2 v2.12.3 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.53.0 // indirect
github.com/prometheus/procfs v0.14.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.14.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 // indirect
go.opentelemetry.io/otel v1.25.0 // indirect
go.opentelemetry.io/otel/metric v1.25.0 // indirect
go.opentelemetry.io/otel/trace v1.25.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/appengine/v2 v2.0.5 // indirect
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be // indirect
google.golang.org/grpc v1.63.2 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

172
go.sum
View File

@@ -1,20 +1,22 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.14.0 h1:8aLcKnMPoldYU3YHgu4t2exrKhLQkqaXAGqT0ljrFVw=
cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ=
cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
cloud.google.com/go/longrunning v0.5.4 h1:w8xEcbZodnA2BbW6sVirkkoC+1gP8wS57EUUgGS0GVg=
cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI=
cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w=
cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
firebase.google.com/go/v4 v4.12.1 h1:tDNvobifGsx/1HSFLnM0fmNfx/CDZSgsTO2KhZtgpcs=
firebase.google.com/go/v4 v4.12.1/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw=
cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms=
cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs=
cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM=
cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA=
cloud.google.com/go/longrunning v0.5.6 h1:xAe8+0YaWoCKr9t1+aWe+OeQgN/iJK1fEgZSXmjuEaE=
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
cloud.google.com/go/storage v1.40.0 h1:VEpDQV5CJxFmJ6ueWNsKxcr1QAYOXEgxDa+sBbJahPw=
cloud.google.com/go/storage v1.40.0/go.mod h1:Rrj7/hKlG87BLqDJYtwR0fbPld8uJPbQ2ucUMY7Ir0g=
firebase.google.com/go/v4 v4.14.0 h1:Tc9jWzMUApUFUA5UUx/HcBeZ+LPjlhG2vNRfWJrcMwU=
firebase.google.com/go/v4 v4.14.0/go.mod h1:pLATyL6xH2o9AMe7rqHdmmOUE/Ph7wcwepIs+uiEKPg=
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 v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -29,12 +31,12 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3
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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -48,8 +50,15 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
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.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -70,9 +79,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -87,12 +95,12 @@ github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA=
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
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.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
@@ -101,10 +109,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w=
@@ -113,44 +121,57 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s=
github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0 h1:zvpPXY7RfYAGSdYQLjp6zxdJNSYD/+FFoCTQN9IPxBs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.50.0/go.mod h1:BMn8NB1vsxTljvuorms2hyOs8IBuuBEq0pl7ltOfy30=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 h1:cEPbyTSEHlQR89XVlyo78gqluF8Y3oMeBkXGWzQsfXY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0/go.mod h1:DKdbWcT4GH1D0Y3Sqt/PFXt2naRKDWtU+eE6oLdFNA8=
go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k=
go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg=
go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA=
go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s=
go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw=
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM=
go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -169,18 +190,18 @@ golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0=
golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM=
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -192,14 +213,14 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8=
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -209,8 +230,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -223,30 +244,28 @@ 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=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.151.0 h1:FhfXLO/NFdJIzQtCqjpysWwqKk8AzGWBUhMIx67cVDU=
google.golang.org/api v0.151.0/go.mod h1:ccy+MJ6nrYFgE3WgRx/AMXOxOmU8Q4hSa+jjibzhxcg=
google.golang.org/api v0.176.1 h1:DJSXnV6An+NhJ1J+GWtoF2nHEuqB1VNoTfnIbjNvwD4=
google.golang.org/api v0.176.1/go.mod h1:j2MaSDYcvYV1lkZ1+SMW4IeF90SrEyFA+tluDYWRrFg=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/appengine/v2 v2.0.5 h1:4C+F3Cd3L2nWEfSmFEZDPjQvDwL8T0YCeZBysZifP3k=
google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
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-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA=
google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be h1:g4aX8SUFA8V5F4LrSY5EclyGYw1OZN4HS1jTyjB9ZDc=
google.golang.org/genproto v0.0.0-20240415180920-8c6c420018be/go.mod h1:FeSdT5fk+lkxatqJP38MsUicGqHax5cLtmy/6TAuxO4=
google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be h1:Zz7rLWqp0ApfsR/l7+zSHhY3PMiH2xqgxlfYfAfNpoU=
google.golang.org/genproto/googleapis/api v0.0.0-20240415180920-8c6c420018be/go.mod h1:dvdCTIoAGbkWbcIKBniID56/7XHTt6WfxXNMxuziJ+w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be h1:LG9vZxsWGOmUKieR8wPAUR3u3MpnYFQZROPIMaXh7/A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240415180920-8c6c420018be/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -257,10 +276,9 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -64,39 +64,40 @@ markdown_extensions:
- attr_list
- md_in_html
- pymdownx.emoji:
emoji_generator: !!python/name:materialx.emoji.to_svg
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
hooks:
- docs/hooks.py
plugins:
- search
- minify:
minify_html: true
- mkdocs-simple-hooks:
hooks:
on_post_build: "docs.hooks:copy_fonts"
nav:
- "Getting started": index.md
- "Publishing":
- "Sending messages": publish.md
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web app": subscribe/web.md
- "From the Desktop": subscribe/pwa.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":
- "Installation": install.md
- "Configuration": config.md
- "Other things":
- "FAQs": faq.md
- "Examples": examples.md
- "Integrations + projects": integrations.md
- "Release notes": releases.md
- "Emojis 🥳 🎉": emojis.md
- "Troubleshooting": troubleshooting.md
- "Known issues": known-issues.md
- "Deprecation notices": deprecations.md
- "Development": develop.md
- "Privacy policy": privacy.md
- "Getting started": index.md
- "Publishing":
- "Sending messages": publish.md
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web app": subscribe/web.md
- "From the Desktop": subscribe/pwa.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":
- "Installation": install.md
- "Configuration": config.md
- "Other things":
- "FAQs": faq.md
- "Examples": examples.md
- "Integrations + projects": integrations.md
- "Release notes": releases.md
- "Emojis 🥳 🎉": emojis.md
- "Troubleshooting": troubleshooting.md
- "Known issues": known-issues.md
- "Deprecation notices": deprecations.md
- "Development": develop.md
- "Privacy policy": privacy.md

View File

@@ -1,4 +1,3 @@
# The documentation uses 'mkdocs', which is written in Python
mkdocs-material
mkdocs-minify-plugin
mkdocs-simple-hooks

View File

@@ -12,11 +12,12 @@ import (
const (
DefaultListenHTTP = ":80"
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!)
DefaultManagerInterval = time.Minute
DefaultDelayedSenderInterval = 10 * time.Second
DefaultMinDelay = 10 * time.Second
DefaultMaxDelay = 3 * 24 * time.Hour
DefaultMessageDelayMin = 10 * time.Second
DefaultMessageDelayMax = 3 * 24 * time.Hour
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
@@ -34,7 +35,7 @@ const (
// - total topic limit: max number of topics overall
// - various attachment limits
const (
DefaultMessageLengthLimit = 4096 // Bytes
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
DefaultTotalTopicLimit = 15000
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
@@ -122,9 +123,9 @@ type Config struct {
MetricsEnable bool
MetricsListenHTTP string
ProfileListenHTTP string
MessageLimit int
MinDelay time.Duration
MaxDelay time.Duration
MessageDelayMin time.Duration
MessageDelayMax time.Duration
MessageSizeLimit int
TotalTopicLimit int
TotalAttachmentSizeLimit int64
VisitorSubscriptionLimit int
@@ -211,9 +212,9 @@ func NewConfig() *Config {
TwilioPhoneNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "",
MessageLimit: DefaultMessageLengthLimit,
MinDelay: DefaultMinDelay,
MaxDelay: DefaultMaxDelay,
MessageSizeLimit: DefaultMessageSizeLimit,
MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax,
TotalTopicLimit: DefaultTotalTopicLimit,
TotalAttachmentSizeLimit: 0,
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,

View File

@@ -89,7 +89,7 @@ var (
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
@@ -113,10 +113,15 @@ var (
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil}
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

View File

@@ -4,332 +4,81 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
"net/netip"
"strings"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
var (
errUnexpectedMessageType = errors.New("unexpected message type")
errMessageNotFound = errors.New("message not found")
errNoRows = errors.New("no rows found")
)
// Messages cache
const (
createMessagesTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
time INT 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_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;
`
insertMessageQuery = `
INSERT INTO messages (mid, time, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesByIDQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE mid = ?
`
selectMessagesSinceTimeQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, time, 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
`
selectMessagesSinceIDQuery = `
SELECT mid, time, 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 > ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, time, 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 > ? OR published = 0)
ORDER BY time, id
`
selectMessagesDueQuery = `
SELECT mid, time, 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
`
selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
selectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'`
updateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'`
)
// Schema management queries
const (
currentSchemaVersion = 12
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
// 0 -> 1
migrate0To1AlterMessagesTableQuery = `
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
migrate1To2AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
`
// 2 -> 3
migrate2To3AlterMessagesTableQuery = `
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
migrate3To4AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
`
// 4 -> 5
migrate4To5AlterMessagesTableQuery = `
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
migrate5To6AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
`
// 6 -> 7
migrate6To7AlterMessagesTableQuery = `
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
`
// 7 -> 8
migrate7To8AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
`
// 8 -> 9
migrate8To9AlterMessagesTableQuery = `
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
`
// 9 -> 10
migrate9To10AlterMessagesTableQuery = `
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);
`
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
// 10 -> 11
migrate10To11AlterMessagesTableQuery = `
CREATE TABLE IF NOT EXISTS stats (
key TEXT PRIMARY KEY,
value INT
);
INSERT INTO stats (key, value) VALUES ('messages', 0);
`
// 11 -> 12
migrate11To12AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
`
)
var (
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
0: migrateFrom0,
1: migrateFrom1,
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
6: migrateFrom6,
7: migrateFrom7,
8: migrateFrom8,
9: migrateFrom9,
10: migrateFrom10,
11: migrateFrom11,
}
)
type messageCache struct {
db *sql.DB
queue *util.BatchingQueue[*message]
nop bool
type MessageCache interface {
AddMessage(m *message) error
AddMessages(ms []*message) error
Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error)
MessagesDue() ([]*message, error)
MessagesExpired() ([]string, error)
Message(id string) (*message, error)
MarkPublished(m *message) error
MessageCounts() (map[string]int, error)
Topics() (map[string]*topic, error)
DeleteMessages(ids ...string) error
ExpireMessages(topics ...string) error
AttachmentsExpired() ([]string, error)
MarkAttachmentsDeleted(ids ...string) error
AttachmentBytesUsedBySender(sender string) (int64, error)
AttachmentBytesUsedByUser(userID string) (int64, error)
UpdateStats(messages int64) error
Stats() (messages int64, err error)
DB() *sql.DB
Close() error
}
// newSqliteCache creates a SQLite file-backed cache
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil {
return nil, err
}
var queue *util.BatchingQueue[*message]
if batchSize > 0 || batchTimeout > 0 {
queue = util.NewBatchingQueue[*message](batchSize, batchTimeout)
}
cache := &messageCache{
db: db,
queue: queue,
nop: nop,
}
go cache.processMessageBatches()
return cache, nil
type commonMessageCache struct {
db *sql.DB
queue *util.BatchingQueue[*message]
queries *messageCacheQueries
}
// newMemCache creates an in-memory cache
func newMemCache() (*messageCache, error) {
return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, false)
}
var _ MessageCache = (*commonMessageCache)(nil)
// newNopCache creates an in-memory cache that discards all messages;
// it is always empty and can be used if caching is entirely disabled
func newNopCache() (*messageCache, error) {
return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, true)
}
type messageCacheQueries struct {
insertMessage string
deleteMessage string
updateMessagesForTopicExpiry string
selectRowIDFromMessageID string // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesByID string
selectMessagesSinceTime string
selectMessagesSinceTimeIncludeScheduled string
selectMessagesSinceID string
selectMessagesSinceIDIncludeScheduled string
selectMessagesDue string
selectMessagesExpired string
updateMessagePublished string
selectMessageCountPerTopic string
selectTopics string
// 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))
updateAttachmentDeleted string
selectAttachmentsExpired string
selectAttachmentsSizeBySender string
selectAttachmentsSizeByUserID string
selectStats string
updateStats string
}
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously.
// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor.
func (c *messageCache) AddMessage(m *message) error {
func (c *commonMessageCache) AddMessage(m *message) error {
if c.queue != nil {
c.queue.Enqueue(m)
return nil
}
return c.addMessages([]*message{m})
return c.AddMessages([]*message{m})
}
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
// AddMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
// SQLite's busy_timeout is exceeded before erroring out.
func (c *messageCache) addMessages(ms []*message) error {
if c.nop {
return nil
}
func (c *commonMessageCache) AddMessages(ms []*message) error {
if len(ms) == 0 {
return nil
}
@@ -339,7 +88,7 @@ func (c *messageCache) addMessages(ms []*message) error {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(insertMessageQuery)
stmt, err := tx.Prepare(c.queries.insertMessage)
if err != nil {
return err
}
@@ -351,7 +100,8 @@ func (c *messageCache) addMessages(ms []*message) error {
published := m.Time <= time.Now().Unix()
tags := strings.Join(m.Tags, ",")
var attachmentName, attachmentType, attachmentURL string
var attachmentSize, attachmentExpires, attachmentDeleted int64
var attachmentSize, attachmentExpires int64
var attachmentDeleted bool
if m.Attachment != nil {
attachmentName = m.Attachment.Name
attachmentType = m.Attachment.Type
@@ -388,7 +138,7 @@ func (c *messageCache) addMessages(ms []*message) error {
attachmentSize,
attachmentExpires,
attachmentURL,
attachmentDeleted, // Always zero
attachmentDeleted, // Always false
sender,
m.User,
m.ContentType,
@@ -407,7 +157,7 @@ func (c *messageCache) addMessages(ms []*message) error {
return nil
}
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
func (c *commonMessageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
if since.IsNone() {
return make([]*message, 0), nil
} else if since.IsID() {
@@ -416,13 +166,13 @@ func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool)
return c.messagesSinceTime(topic, since, scheduled)
}
func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
func (c *commonMessageCache) messagesSinceTime(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
var rows *sql.Rows
var err error
if scheduled {
rows, err = c.db.Query(selectMessagesSinceTimeIncludeScheduledQuery, topic, since.Time().Unix())
rows, err = c.db.Query(c.queries.selectMessagesSinceTimeIncludeScheduled, topic, since.Time().Unix())
} else {
rows, err = c.db.Query(selectMessagesSinceTimeQuery, topic, since.Time().Unix())
rows, err = c.db.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
}
if err != nil {
return nil, err
@@ -430,8 +180,8 @@ func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, schedu
return readMessages(rows)
}
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
idrows, err := c.db.Query(selectRowIDFromMessageID, since.ID())
func (c *commonMessageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
idrows, err := c.db.Query(c.queries.selectRowIDFromMessageID, since.ID())
if err != nil {
return nil, err
}
@@ -446,9 +196,9 @@ func (c *messageCache) messagesSinceID(topic string, since sinceMarker, schedule
idrows.Close()
var rows *sql.Rows
if scheduled {
rows, err = c.db.Query(selectMessagesSinceIDIncludeScheduledQuery, topic, rowID)
rows, err = c.db.Query(c.queries.selectMessagesSinceIDIncludeScheduled, topic, rowID)
} else {
rows, err = c.db.Query(selectMessagesSinceIDQuery, topic, rowID)
rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, rowID)
}
if err != nil {
return nil, err
@@ -456,8 +206,8 @@ func (c *messageCache) messagesSinceID(topic string, since sinceMarker, schedule
return readMessages(rows)
}
func (c *messageCache) MessagesDue() ([]*message, error) {
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
func (c *commonMessageCache) MessagesDue() ([]*message, error) {
rows, err := c.db.Query(c.queries.selectMessagesDue, time.Now().Unix())
if err != nil {
return nil, err
}
@@ -465,8 +215,8 @@ func (c *messageCache) MessagesDue() ([]*message, error) {
}
// MessagesExpired returns a list of IDs for messages that have expires (should be deleted)
func (c *messageCache) MessagesExpired() ([]string, error) {
rows, err := c.db.Query(selectMessagesExpiredQuery, time.Now().Unix())
func (c *commonMessageCache) MessagesExpired() ([]string, error) {
rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix())
if err != nil {
return nil, err
}
@@ -485,25 +235,24 @@ func (c *messageCache) MessagesExpired() ([]string, error) {
return ids, nil
}
func (c *messageCache) Message(id string) (*message, error) {
rows, err := c.db.Query(selectMessagesByIDQuery, id)
func (c *commonMessageCache) Message(id string) (*message, error) {
rows, err := c.db.Query(c.queries.selectMessagesByID, id)
if err != nil {
return nil, err
}
if !rows.Next() {
} else if !rows.Next() {
return nil, errMessageNotFound
}
defer rows.Close()
return readMessage(rows)
}
func (c *messageCache) MarkPublished(m *message) error {
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
func (c *commonMessageCache) MarkPublished(m *message) error {
_, err := c.db.Exec(c.queries.updateMessagePublished, m.ID)
return err
}
func (c *messageCache) MessageCounts() (map[string]int, error) {
rows, err := c.db.Query(selectMessageCountPerTopicQuery)
func (c *commonMessageCache) MessageCounts() (map[string]int, error) {
rows, err := c.db.Query(c.queries.selectMessageCountPerTopic)
if err != nil {
return nil, err
}
@@ -522,8 +271,8 @@ func (c *messageCache) MessageCounts() (map[string]int, error) {
return counts, nil
}
func (c *messageCache) Topics() (map[string]*topic, error) {
rows, err := c.db.Query(selectTopicsQuery)
func (c *commonMessageCache) Topics() (map[string]*topic, error) {
rows, err := c.db.Query(c.queries.selectTopics)
if err != nil {
return nil, err
}
@@ -542,36 +291,36 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
return topics, nil
}
func (c *messageCache) DeleteMessages(ids ...string) error {
func (c *commonMessageCache) DeleteMessages(ids ...string) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if _, err := tx.Exec(deleteMessageQuery, id); err != nil {
if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil {
return err
}
}
return tx.Commit()
}
func (c *messageCache) ExpireMessages(topics ...string) error {
func (c *commonMessageCache) ExpireMessages(topics ...string) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, t := range topics {
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil {
if _, err := tx.Exec(c.queries.updateMessagesForTopicExpiry, time.Now().Unix()-1, t); err != nil {
return err
}
}
return tx.Commit()
}
func (c *messageCache) AttachmentsExpired() ([]string, error) {
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
func (c *commonMessageCache) AttachmentsExpired() ([]string, error) {
rows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix())
if err != nil {
return nil, err
}
@@ -590,37 +339,37 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
return ids, nil
}
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
func (c *commonMessageCache) MarkAttachmentsDeleted(ids ...string) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if _, err := tx.Exec(updateAttachmentDeleted, id); err != nil {
if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil {
return err
}
}
return tx.Commit()
}
func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeBySenderQuery, sender, time.Now().Unix())
func (c *commonMessageCache) 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)
}
func (c *messageCache) AttachmentBytesUsedByUser(userID string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeByUserIDQuery, userID, time.Now().Unix())
func (c *commonMessageCache) 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 *messageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
func (c *commonMessageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
defer rows.Close()
var size int64
if !rows.Next() {
@@ -634,17 +383,45 @@ func (c *messageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
return size, nil
}
func (c *messageCache) processMessageBatches() {
func (c *commonMessageCache) processMessageBatches() {
if c.queue == nil {
return
}
for messages := range c.queue.Dequeue() {
if err := c.addMessages(messages); err != nil {
if err := c.AddMessages(messages); err != nil {
log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
}
}
}
func (c *commonMessageCache) UpdateStats(messages int64) error {
_, err := c.db.Exec(c.queries.updateStats, messages)
return err
}
func (c *commonMessageCache) 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
}
func (c *commonMessageCache) DB() *sql.DB {
return c.db
}
func (c *commonMessageCache) Close() error {
return c.db.Close()
}
func readMessages(rows *sql.Rows) ([]*message, error) {
defer rows.Close()
messages := make([]*message, 0)
@@ -734,239 +511,3 @@ func readMessage(rows *sql.Rows) (*message, error) {
Encoding: encoding,
}, nil
}
func (c *messageCache) UpdateStats(messages int64) error {
_, err := c.db.Exec(updateStatsQuery, messages)
return err
}
func (c *messageCache) Stats() (messages int64, err error) {
rows, err := c.db.Query(selectStatsQuery)
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
}
func (c *messageCache) Close() error {
return c.db.Close()
}
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
}
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(selectMessagesCountQuery)
if err != nil {
return setupNewCacheDB(db)
}
rowsMC.Close()
// If 'messages' table exists, check 'schemaVersion' table
schemaVersion := 0
rowsSV, err := db.Query(selectSchemaVersionQuery)
if err == nil {
defer rowsSV.Close()
if !rowsSV.Next() {
return errors.New("cannot determine schema version: cache file may be corrupt")
}
if err := rowsSV.Scan(&schemaVersion); err != nil {
return err
}
rowsSV.Close()
}
// Do migrations
if schemaVersion == currentSchemaVersion {
return nil
} else if schemaVersion > currentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion)
}
for i := schemaVersion; i < currentSchemaVersion; i++ {
fn, ok := migrations[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 setupNewCacheDB(db *sql.DB) error {
if _, err := db.Exec(createMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
return err
}
return nil
}
func migrateFrom0(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
return err
}
return nil
}
func migrateFrom1(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
return err
}
return nil
}
func migrateFrom2(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
return err
}
return nil
}
func migrateFrom3(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
return err
}
return nil
}
func migrateFrom4(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
return err
}
return nil
}
func migrateFrom5(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
return err
}
return nil
}
func migrateFrom6(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
return err
}
return nil
}
func migrateFrom7(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
return err
}
return nil
}
func migrateFrom8(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 9); err != nil {
return err
}
return nil
}
func migrateFrom9(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(migrate9To10AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(migrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom10(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(migrate10To11AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 11); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom11(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(migrate11To12AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 12); err != nil {
return err
}
return tx.Commit()
}

179
server/message_cache_pg.go Normal file
View File

@@ -0,0 +1,179 @@
package server
import (
"database/sql"
_ "github.com/lib/pq" // PostgreSQL driver
"heckel.io/ntfy/v2/util"
"time"
)
// Messages cache
const (
pgCreateMessagesTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
mid TEXT NOT NULL,
time INT 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 BOOLEAN NOT NULL,
sender TEXT NOT NULL,
"user" TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published BOOLEAN 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 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;
`
pgSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
)
var (
pgMessageCacheQueries = &messageCacheQueries{
insertMessage: `
INSERT INTO messages (mid, time, 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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
`,
deleteMessage: `DELETE FROM messages WHERE mid = $1`,
updateMessagesForTopicExpiry: `UPDATE messages SET expires = $1 WHERE topic = $2`,
selectRowIDFromMessageID: `SELECT id FROM messages WHERE mid = $1`, // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesByID: `
SELECT mid, time, 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 = $1
`,
selectMessagesSinceTime: `
SELECT mid, time, 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 = $1 AND time >= $2 AND published = TRUE
ORDER BY time, id
`,
selectMessagesSinceTimeIncludeScheduled: `
SELECT mid, time, 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 = $1 AND time >= $2
ORDER BY time, id
`,
selectMessagesSinceID: `
SELECT mid, time, 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 = $1 AND id > $2 AND published = TRUE
ORDER BY time, id
`,
selectMessagesSinceIDIncludeScheduled: `
SELECT mid, time, 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 = $1 AND (id > $2 OR published = FALSE)
ORDER BY time, id
`,
selectMessagesDue: `
SELECT mid, time, 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 <= $1 AND published = FALSE
ORDER BY time, id
`,
selectMessagesExpired: `SELECT mid FROM messages WHERE expires <= $1 AND published = TRUE`,
updateMessagePublished: `UPDATE messages SET published = TRUE WHERE mid = $1`,
selectMessageCountPerTopic: `SELECT topic, COUNT(*) FROM messages GROUP BY topic`,
selectTopics: `SELECT topic FROM messages GROUP BY topic`,
updateAttachmentDeleted: `UPDATE messages SET attachment_deleted = TRUE WHERE mid = $1`,
selectAttachmentsExpired: `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE`,
selectAttachmentsSizeBySender: `SELECT COALESCE(SUM(attachment_size), 0) FROM messages WHERE "user" = '' AND sender = $1 AND attachment_expires >= $2`,
selectAttachmentsSizeByUserID: `SELECT COALESCE(SUM(attachment_size), 0) FROM messages WHERE "user" = $1 AND attachment_expires >= $2`,
selectStats: `SELECT value FROM stats WHERE key = 'messages'`,
updateStats: `UPDATE stats SET value = $1 WHERE key = 'messages'`,
}
)
type pgMessageCache struct {
*commonMessageCache
}
var _ MessageCache = (*pgMessageCache)(nil)
func newPgMessageCache(connectionString, startupQueries string, batchSize int, batchTimeout time.Duration) (*pgMessageCache, error) {
db, err := sql.Open("postgres", connectionString)
if err != nil {
return nil, err
}
if err := setupPgMessagesDB(db, startupQueries); err != nil {
return nil, err
}
var queue *util.BatchingQueue[*message]
if batchSize > 0 || batchTimeout > 0 {
queue = util.NewBatchingQueue[*message](batchSize, batchTimeout)
}
cache := &pgMessageCache{
commonMessageCache: &commonMessageCache{
db: db,
queue: queue,
queries: pgMessageCacheQueries,
},
}
go cache.processMessageBatches()
return cache, nil
}
func setupPgMessagesDB(db *sql.DB, startupQueries string) error {
// Run startup queries
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
}
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(pgSelectMessagesCountQuery)
if err != nil {
return setupNewPgCacheDB(db)
}
rowsMC.Close()
return nil
// FIXME schema migration
}
func setupNewPgCacheDB(db *sql.DB) error {
if _, err := db.Exec(pgCreateMessagesTableQuery); err != nil {
return err
}
/*
// FIXME
if _, err := db.Exec(pgCreateSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
return err
}
*/
return nil
}

View File

@@ -0,0 +1,542 @@
package server
import (
"database/sql"
"errors"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
var (
errUnexpectedMessageType = errors.New("unexpected message type")
errMessageNotFound = errors.New("message not found")
errNoRows = errors.New("no rows found")
)
// Messages cache
const (
createMessagesTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL,
time INT 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_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;
`
)
var (
sqliteMessageCacheQueries = &messageCacheQueries{
insertMessage: `
INSERT INTO messages (mid, time, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
deleteMessage: `DELETE FROM messages WHERE mid = ?`,
updateMessagesForTopicExpiry: `UPDATE messages SET expires = ? WHERE topic = ?`,
selectRowIDFromMessageID: `SELECT id FROM messages WHERE mid = ?`, // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesByID: `
SELECT mid, time, 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 = ?
`,
selectMessagesSinceTime: `
SELECT mid, time, 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
`,
selectMessagesSinceTimeIncludeScheduled: `
SELECT mid, time, 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
`,
selectMessagesSinceID: `
SELECT mid, time, 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 > ? AND published = 1
ORDER BY time, id
`,
selectMessagesSinceIDIncludeScheduled: `
SELECT mid, time, 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 > ? OR published = 0)
ORDER BY time, id
`,
selectMessagesDue: `
SELECT mid, time, 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
`,
selectMessagesExpired: `SELECT mid FROM messages WHERE expires <= ? AND published = 1`,
updateMessagePublished: `UPDATE messages SET published = 1 WHERE mid = ?`,
selectMessageCountPerTopic: `SELECT topic, COUNT(*) FROM messages GROUP BY topic`,
selectTopics: `SELECT topic FROM messages GROUP BY topic`,
updateAttachmentDeleted: `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`,
selectAttachmentsExpired: `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`,
selectAttachmentsSizeBySender: `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`,
selectAttachmentsSizeByUserID: `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`,
selectStats: `SELECT value FROM stats WHERE key = 'messages'`,
updateStats: `UPDATE stats SET value = ? WHERE key = 'messages'`,
}
)
// Schema management queries
const (
currentSchemaVersion = 12
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
// 0 -> 1
migrate0To1AlterMessagesTableQuery = `
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
migrate1To2AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
`
// 2 -> 3
migrate2To3AlterMessagesTableQuery = `
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
migrate3To4AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
`
// 4 -> 5
migrate4To5AlterMessagesTableQuery = `
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
migrate5To6AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
`
// 6 -> 7
migrate6To7AlterMessagesTableQuery = `
ALTER TABLE messages RENAME COLUMN attachment_owner TO sender;
`
// 7 -> 8
migrate7To8AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN icon TEXT NOT NULL DEFAULT('');
`
// 8 -> 9
migrate8To9AlterMessagesTableQuery = `
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
`
// 9 -> 10
migrate9To10AlterMessagesTableQuery = `
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);
`
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
// 10 -> 11
migrate10To11AlterMessagesTableQuery = `
CREATE TABLE IF NOT EXISTS stats (
key TEXT PRIMARY KEY,
value INT
);
INSERT INTO stats (key, value) VALUES ('messages', 0);
`
// 11 -> 12
migrate11To12AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN content_type TEXT NOT NULL DEFAULT('');
`
)
var (
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
0: migrateFrom0,
1: migrateFrom1,
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
6: migrateFrom6,
7: migrateFrom7,
8: migrateFrom8,
9: migrateFrom9,
10: migrateFrom10,
11: migrateFrom11,
}
)
type sqliteMessageCache struct {
*commonMessageCache
nop bool
}
var _ MessageCache = (*sqliteMessageCache)(nil)
// newSqliteMessageCache creates a SQLite file-backed cache
func newSqliteMessageCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*sqliteMessageCache, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil {
return nil, err
}
var queue *util.BatchingQueue[*message]
if batchSize > 0 || batchTimeout > 0 {
queue = util.NewBatchingQueue[*message](batchSize, batchTimeout)
}
cache := &sqliteMessageCache{
commonMessageCache: &commonMessageCache{
db: db,
queue: queue,
queries: sqliteMessageCacheQueries,
},
nop: nop,
}
go cache.processMessageBatches()
return cache, nil
}
// newMemCache creates an in-memory cache
func newMemCache() (*sqliteMessageCache, error) {
return newSqliteMessageCache(createMemoryFilename(), "", 0, 0, 0, false)
}
// newNopCache creates an in-memory cache that discards all messages;
// it is always empty and can be used if caching is entirely disabled
func newNopCache() (*sqliteMessageCache, error) {
return newSqliteMessageCache(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))
}
// AddMessage stores a message to the message cache synchronously, or queues it to be stored at a later date asyncronously.
// The message is queued only if "batchSize" or "batchTimeout" are passed to the constructor.
func (c *sqliteMessageCache) AddMessage(m *message) error {
if c.nop {
return nil
}
return c.commonMessageCache.AddMessage(m)
}
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
}
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(selectMessagesCountQuery)
if err != nil {
return setupNewCacheDB(db)
}
rowsMC.Close()
// If 'messages' table exists, check 'schemaVersion' table
schemaVersion := 0
rowsSV, err := db.Query(selectSchemaVersionQuery)
if err == nil {
defer rowsSV.Close()
if !rowsSV.Next() {
return errors.New("cannot determine schema version: cache file may be corrupt")
}
if err := rowsSV.Scan(&schemaVersion); err != nil {
return err
}
rowsSV.Close()
}
// Do migrations
if schemaVersion == currentSchemaVersion {
return nil
} else if schemaVersion > currentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion)
}
for i := schemaVersion; i < currentSchemaVersion; i++ {
fn, ok := migrations[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 setupNewCacheDB(db *sql.DB) error {
if _, err := db.Exec(createMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
return err
}
return nil
}
func migrateFrom0(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
return err
}
return nil
}
func migrateFrom1(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
return err
}
return nil
}
func migrateFrom2(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
return err
}
return nil
}
func migrateFrom3(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
return err
}
return nil
}
func migrateFrom4(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
return err
}
return nil
}
func migrateFrom5(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
return err
}
return nil
}
func migrateFrom6(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
return err
}
return nil
}
func migrateFrom7(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
return err
}
return nil
}
func migrateFrom8(db *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 9); err != nil {
return err
}
return nil
}
func migrateFrom9(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(migrate9To10AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(migrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom10(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(migrate10To11AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 11); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom11(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(migrate11To12AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 12); err != nil {
return err
}
return tx.Commit()
}

View File

@@ -0,0 +1,254 @@
package server
import (
"database/sql"
"fmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
"time"
)
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))
}
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 := newSqliteMessageCache(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 := newSqliteMessageCache(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 := newSqliteMessageCache(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 := newSqliteMessageCache(filename, startupQueries, time.Hour, 0, 0, false)
require.Error(t, err)
}
func TestMemCache_NopCache(t *testing.T) {
c, _ := newNopCache()
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
messages, err := c.Messages("mytopic", sinceAllMessages, false)
assert.Nil(t, err)
assert.Empty(t, messages)
topics, err := c.Topics()
assert.Nil(t, err)
assert.Empty(t, topics)
}
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())
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@ import (
"strconv"
"strings"
"sync"
"text/template"
"time"
"unicode/utf8"
@@ -52,7 +53,7 @@ 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
messageCache MessageCache // Database that stores the messages
webPush *webPushStore // 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
@@ -123,15 +124,22 @@ var (
const (
firebaseControlTopic = "~control" // See Android if changed
firebasePollTopic = "~poll" // See iOS if changed
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
emptyMessageBody = "triggered" // Used if message body is empty
newMessageBody = "New message" // Used in poll requests as generic message
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body
jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher)
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
messagesHistoryMax = 10 // Number of message count values to keep in memory
templateMaxExecutionTime = 100 * time.Millisecond
)
var (
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
// are not useful, and seem potentially troublesome.
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
)
// WebSocket constants
@@ -218,11 +226,13 @@ func New(conf *Config) (*Server, error) {
return s, nil
}
func createMessageCache(conf *Config) (*messageCache, error) {
func createMessageCache(conf *Config) (MessageCache, error) {
if conf.CacheDuration == 0 {
return newNopCache()
} else if strings.HasPrefix(conf.CacheFile, "postgres:") {
return newPgMessageCache(strings.TrimPrefix(conf.CacheFile, "postgres:"), conf.CacheStartupQueries, conf.CacheBatchSize, conf.CacheBatchTimeout)
} else if conf.CacheFile != "" {
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
return newSqliteMessageCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
}
return newMemCache()
}
@@ -673,7 +683,7 @@ 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 err == errMessageNotFound {
if errors.Is(err, 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
@@ -733,18 +743,18 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if err != nil {
return nil, err
}
body, err := util.Peek(r.Body, s.config.MessageLimit)
body, err := util.Peek(r.Body, s.config.MessageSizeLimit)
if err != nil {
return nil, err
}
m := newDefaultMessage(t.ID, "")
cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m)
if e != nil {
return nil, e.With(t)
}
if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see
// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting.
// The 5xx response is because some app servers (in particular Mastodon) will remove
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
@@ -769,7 +779,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if cache {
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
}
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
return nil, err
}
if m.Message == "" {
@@ -872,7 +882,7 @@ func (s *Server) sendToFirebase(v *visitor, m *message) {
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
if err := s.firebaseClient.Send(v, m); err != nil {
minc(metricFirebasePublishedFailure)
if err == errFirebaseTemporarilyBanned {
if errors.Is(err, errFirebaseTemporarilyBanned) {
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
} else {
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
@@ -924,7 +934,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
}
}
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) {
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t")
@@ -940,7 +950,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
}
if attach != "" {
if !urlRegex.MatchString(attach) {
return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
if m.Attachment.Name == "" {
@@ -958,19 +968,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, 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, 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, false, errHTTPBadRequestPhoneCallsDisabled
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
}
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" {
@@ -979,27 +989,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
var e error
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if e != nil {
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
}
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, false, errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
return false, 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, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
}
@@ -1007,13 +1017,14 @@ 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(e.Error())
return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
}
}
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
if markdown || strings.ToLower(contentType) == "text/markdown" {
m.ContentType = "text/markdown"
}
template = readBoolParam(r, false, "x-template", "template", "tpl")
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush {
firebase = false
@@ -1025,7 +1036,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
cache = false
email = ""
}
return cache, firebase, email, call, unifiedpush, nil
return cache, firebase, email, call, template, unifiedpush, nil
}
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
@@ -1033,16 +1044,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
// If a message is flagged as poll request, the body does not matter and is discarded
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
// If body is binary, encode as base64, if not do not encode
// If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
// Body must be a message, because we attached an external URL
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
// Body must be attachment, because we passed a filename
// 5. curl -T file.txt ntfy.sh/mytopic
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
// If templating is enabled, read up to 32k and treat message body as JSON
// 6. curl -T file.txt ntfy.sh/mytopic
// 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, unifiedpush bool) error {
// 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, unifiedpush bool) error {
if m.Event == pollRequestEvent { // Case 1
return s.handleBodyDiscard(body)
} else if unifiedpush {
@@ -1051,10 +1064,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
return s.handleBodyAsTextMessage(m, body) // Case 3
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
} else if template {
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 5
return s.handleBodyAsTextMessage(m, body) // Case 6
}
return s.handleBodyAsAttachment(r, v, m, body) // Case 6
return s.handleBodyAsAttachment(r, v, m, body) // Case 7
}
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
@@ -1086,6 +1101,45 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
return nil
}
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil {
return err
} else if body.LimitReached {
return errHTTPEntityTooLargeJSONBody
}
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
return err
}
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
return err
}
if len(m.Message) > s.config.MessageSizeLimit {
return errHTTPBadRequestTemplateMessageTooLarge
}
return nil
}
func replaceTemplate(tpl string, source string) (string, error) {
if templateDisallowedRegex.MatchString(tpl) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
}
var data any
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := template.New("").Parse(tpl)
if err != nil {
return "", errHTTPBadRequestTemplateInvalid
}
var buf bytes.Buffer
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
return "", errHTTPBadRequestTemplateExecuteFailed
}
return buf.String(), nil
}
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
return errHTTPBadRequestAttachmentsDisallowed.With(m)
@@ -1128,7 +1182,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
}
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
if err == util.ErrLimitReached {
if errors.Is(err, util.ErrLimitReached) {
return errHTTPEntityTooLargeAttachment.With(m)
} else if err != nil {
return err
@@ -1182,7 +1236,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
if err != nil {
return err
}
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil {
return err
}
@@ -1212,7 +1266,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
}
return nil
}
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
return err
}
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@@ -1278,7 +1332,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
if err != nil {
return err
}
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil {
return err
}
@@ -1364,7 +1418,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
}
return conn.WriteJSON(msg)
}
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
return err
}
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@@ -1397,7 +1451,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
return err
}
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) {
func parseSubscribeParams(r *http.Request) (poll bool, since 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)
@@ -1408,7 +1462,6 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
if err != nil {
return
}
rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics")
return
}
@@ -1420,9 +1473,8 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
// - or the topic is reserved, and v.user is the owner
// - or the topic is not reserved, and v.user has write access
//
// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition
// until the Android app will send the "Rate-Topics" header.
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
// This only applies to UnifiedPush topics ("up...").
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic) error {
// Bail out if not enabled
if !s.config.VisitorSubscriberRateLimiting {
return nil
@@ -1431,7 +1483,7 @@ func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*top
// Make a list of topics that we'll actually set the RateVisitor on
eligibleRateTopics := make([]*topic, 0)
for _, t := range topics {
if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) {
if strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength {
eligibleRateTopics = append(eligibleRateTopics, t)
}
}
@@ -1475,7 +1527,7 @@ func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topi
return nil
}
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
// sendOldMessages selects old messages from the sqliteMessageCache 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 {
if since.IsNone() {
@@ -1756,7 +1808,7 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageSizeLimit*2, false) // 2x to account for JSON format overhead
if err != nil {
return err
}
@@ -1814,7 +1866,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageSizeLimit)
if err != nil {
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
if e, ok := err.(*errMatrixPushkeyRejected); ok {

View File

@@ -236,6 +236,16 @@
# upstream-base-url:
# upstream-access-token:
# Configures message-specific limits
#
# - message-size-limit defines the max size of a message body. Please note message sizes >4K are NOT RECOMMENDED,
# and largely untested. If FCM and/or APNS is used, the limit should stay 4K, because their limits are around that size.
# If you increase this size limit regardless, FCM and APNS will NOT work for large messages.
# - message-delay-limit defines the max delay of a message when using the "Delay" header.
#
# message-size-limit: "4k"
# message-delay-limit: "3d"
# Rate limiting: Total number of topics before the server rejects new topics.
#
# global-topic-limit: 15000
@@ -277,15 +287,14 @@
# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush)
#
# If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
# publishers (e.g. Matrix/Mastodon servers) are allowed to send.
# If subscriber-based rate limiting is enabled, messages published on UnifiedPush topics** (topics starting with "up")
# will be counted towards the "rate visitor" of the topic. A "rate visitor" is the first subscriber to the topic.
#
# Once enabled, a client may send a "Rate-Topics: <topic1>,<topic2>,..." header when subscribing to topics via
# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
# to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic.
# Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as
# a "rate visitor", i.e. the visitor whose rate limits will be used when publishing on this topic. Note that setting the rate visitor
# requires **read-write permission** on the topic.
#
# UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if
# If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if
# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit".
#
# visitor-subscriber-rate-limiting: false

View File

@@ -718,11 +718,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "starter",
MessageLimit: 10,
MessageSizeLimit: 10,
}))
require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "pro",
MessageLimit: 20,
MessageSizeLimit: 20,
}))
require.Nil(t, s.userManager.ChangeTier("phil", "starter"))

View File

@@ -1,6 +1,7 @@
package server
import (
"errors"
"heckel.io/ntfy/v2/user"
"net/http"
)
@@ -45,7 +46,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
return errHTTPBadRequest.Wrap("username invalid, or password missing")
}
u, err := s.userManager.User(req.Username)
if err != nil && err != user.ErrUserNotFound {
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
return err
} else if u != nil {
return errHTTPConflictUserExists
@@ -53,7 +54,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
var tier *user.Tier
if req.Tier != "" {
tier, err = s.userManager.Tier(req.Tier)
if err == user.ErrTierNotFound {
if errors.Is(err, user.ErrTierNotFound) {
return errHTTPBadRequestTierInvalid
} else if err != nil {
return err
@@ -76,7 +77,7 @@ func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *vi
return err
}
u, err := s.userManager.User(req.Username)
if err == user.ErrUserNotFound {
if errors.Is(err, user.ErrUserNotFound) {
return errHTTPBadRequestUserNotFound
} else if err != nil {
return err
@@ -98,7 +99,7 @@ func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *vi
return err
}
_, err = s.userManager.User(req.Username)
if err == user.ErrUserNotFound {
if errors.Is(err, user.ErrUserNotFound) {
return errHTTPBadRequestUserNotFound
} else if err != nil {
return err

File diff suppressed because one or more lines are too long

View File

@@ -150,8 +150,8 @@ func (s *smtpSession) Data(r io.Reader) error {
return err
}
body = strings.TrimSpace(body)
if len(body) > conf.MessageLimit {
body = body[:conf.MessageLimit]
if len(body) > conf.MessageSizeLimit {
body = body[:conf.MessageSizeLimit]
}
m := newDefaultMessage(s.topic, body)
subject := strings.TrimSpace(msg.Header.Get("Subject"))

View File

@@ -2,6 +2,7 @@ package server
import (
"context"
"errors"
"fmt"
"heckel.io/ntfy/v2/util"
"io"
@@ -104,9 +105,9 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
if err == util.ErrUnmarshalJSON {
if errors.Is(err, util.ErrUnmarshalJSON) {
return nil, errHTTPBadRequestJSONInvalid
} else if err == util.ErrTooLargeJSON {
} else if errors.Is(err, util.ErrTooLargeJSON) {
return nil, errHTTPEntityTooLargeJSONBody
} else if err != nil {
return nil, err

View File

@@ -30,10 +30,10 @@ const (
visitorDefaultCallsLimit = int64(0)
)
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
// Constants used to convert a tier-user's MessageSizeLimit (see user.Tier) into adequate request limiter
// values (token bucket). This is only used to increase the values in server.yml, never decrease them.
//
// Example: Assuming a user.Tier's MessageLimit is 10,000:
// Example: Assuming a user.Tier's MessageSizeLimit is 10,000:
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
// - the replenish rate is 2 * 10,000 / 24 hours
const (
@@ -53,7 +53,7 @@ const (
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
type visitor struct {
config *Config
messageCache *messageCache
messageCache MessageCache
userManager *user.Manager // May be nil
ip netip.Addr // Visitor IP address
user *user.User // Only set if authenticated user, otherwise nil
@@ -114,7 +114,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 MessageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
var messages, emails, calls int64
if user != nil {
messages = user.Stats.Messages

View File

@@ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) {
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
port := 10000 + rand.Intn(20000)
port := 10000 + rand.Intn(30000)
conf.ListenHTTP = fmt.Sprintf(":%d", port)
conf.AttachmentCacheDir = t.TempDir()
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")

View File

@@ -2,6 +2,7 @@ package util
import (
"bytes"
"errors"
"io"
"strings"
)
@@ -26,7 +27,7 @@ func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
}
peeked := make([]byte, limit)
read, err := io.ReadFull(underlying, peeked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && err != io.EOF {
return nil, err
}
return &PeekedReadCloser{
@@ -44,7 +45,7 @@ func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
return 0, io.EOF
}
n, err = r.peeked.Read(p)
if err == io.EOF {
if errors.Is(err, io.EOF) {
return r.underlying.Read(p)
} else if err != nil {
return 0, err

View File

@@ -10,8 +10,8 @@ import (
)
var (
errUnparsableTime = errors.New("unable to parse time")
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
errInvalidDuration = errors.New("unable to parse duration")
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
)
const (
@@ -51,7 +51,7 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
if err == nil {
return t, nil
}
return time.Time{}, errUnparsableTime
return time.Time{}, errInvalidDuration
}
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
@@ -65,7 +65,7 @@ func ParseDuration(s string) (time.Duration, error) {
if matches != nil {
number, err := strconv.Atoi(matches[1])
if err != nil {
return 0, errUnparsableTime
return 0, errInvalidDuration
}
switch unit := matches[2][0:1]; unit {
case "d":
@@ -77,10 +77,28 @@ func ParseDuration(s string) (time.Duration, error) {
case "s":
return time.Duration(number) * time.Second, nil
default:
return 0, errUnparsableTime
return 0, errInvalidDuration
}
}
return 0, errUnparsableTime
return 0, errInvalidDuration
}
// FormatDuration formats a time.Duration into a human-readable string, e.g. "2d", "20h", "30m", "40s".
// It rounds to the largest unit that is not zero, thereby effectively rounding down.
func FormatDuration(d time.Duration) string {
if d >= 24*time.Hour {
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
}
if d >= time.Hour {
return strconv.Itoa(int(d/time.Hour)) + "h"
}
if d >= time.Minute {
return strconv.Itoa(int(d/time.Minute)) + "m"
}
if d >= time.Second {
return strconv.Itoa(int(d/time.Second)) + "s"
}
return "0s"
}
func parseFromDuration(s string, now time.Time) (time.Time, error) {
@@ -88,7 +106,7 @@ func parseFromDuration(s string, now time.Time) (time.Time, error) {
if err == nil {
return now.Add(d), nil
}
return time.Time{}, errUnparsableTime
return time.Time{}, errInvalidDuration
}
func parseUnixTime(s string, now time.Time) (time.Time, error) {
@@ -96,7 +114,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
if err != nil {
return time.Time{}, err
} else if int64(t) < now.Unix() {
return time.Time{}, errUnparsableTime
return time.Time{}, errInvalidDuration
}
return time.Unix(int64(t), 0).UTC(), nil
}
@@ -104,7 +122,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
func parseNaturalTime(s string, now time.Time) (time.Time, error) {
r, err := when.EN.Parse(s, now) // returns "nil, nil" if no matches!
if err != nil || r == nil {
return time.Time{}, errUnparsableTime
return time.Time{}, errInvalidDuration
} else if r.Time.After(now) {
return r.Time, nil
}
@@ -112,9 +130,9 @@ func parseNaturalTime(s string, now time.Time) (time.Time, error) {
// simply append "tomorrow, " to it.
r, err = when.EN.Parse("tomorrow, "+s, now) // returns "nil, nil" if no matches!
if err != nil || r == nil {
return time.Time{}, errUnparsableTime
return time.Time{}, errInvalidDuration
} else if r.Time.After(now) {
return r.Time, nil
}
return time.Time{}, errUnparsableTime
return time.Time{}, errInvalidDuration
}

View File

@@ -92,3 +92,27 @@ func TestParseDuration(t *testing.T) {
require.Nil(t, err)
require.Equal(t, time.Duration(0), d)
}
func TestFormatDuration(t *testing.T) {
values := []struct {
duration time.Duration
expected string
}{
{24 * time.Second, "24s"},
{56 * time.Minute, "56m"},
{time.Hour, "1h"},
{2 * time.Hour, "2h"},
{24 * time.Hour, "1d"},
{3 * 24 * time.Hour, "3d"},
}
for _, value := range values {
require.Equal(t, value.expected, FormatDuration(value.duration))
d, err := ParseDuration(FormatDuration(value.duration))
require.Nil(t, err)
require.Equalf(t, value.duration, d, "duration does not match: %v != %v", value.duration, d)
}
}
func TestFormatDuration_Rounded(t *testing.T) {
require.Equal(t, "1d", FormatDuration(47*time.Hour))
}

34
util/timeout_writer.go Normal file
View File

@@ -0,0 +1,34 @@
package util
import (
"errors"
"io"
"time"
)
// ErrWriteTimeout is returned when a write timed out
var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation")
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
type TimeoutWriter struct {
writer io.Writer
timeout time.Duration
start time.Time
}
// NewTimeoutWriter creates a new TimeoutWriter
func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
return &TimeoutWriter{
writer: w,
timeout: timeout,
start: time.Now(),
}
}
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
if time.Since(tw.start) > tw.timeout {
return 0, errors.New("write operation failed due to timeout since creation")
}
return tw.writer.Write(p)
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"math"
"math/rand"
"net/netip"
"os"
@@ -215,6 +216,8 @@ func ParseSize(s string) (int64, error) {
return -1, fmt.Errorf("cannot convert number %s", matches[1])
}
switch strings.ToUpper(matches[2]) {
case "T":
return int64(value) * 1024 * 1024 * 1024 * 1024, nil
case "G":
return int64(value) * 1024 * 1024 * 1024, nil
case "M":
@@ -226,8 +229,23 @@ func ParseSize(s string) (int64, error) {
}
}
// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB
// FormatSize formats the size in a way that it can be parsed by ParseSize.
// It does not include decimal places. Uneven sizes are rounded down.
func FormatSize(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%d%c", int(math.Floor(float64(b)/float64(div))), "KMGT"[exp])
}
// FormatSizeHuman formats bytes into a human-readable notation, e.g. 2.1 MB
func FormatSizeHuman(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d bytes", b)
@@ -237,7 +255,7 @@ func FormatSize(b int64) string {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGT"[exp])
}
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the

View File

@@ -110,33 +110,47 @@ func TestShortTopicURL(t *testing.T) {
func TestParseSize_10GSuccess(t *testing.T) {
s, err := ParseSize("10G")
if err != nil {
t.Fatal(err)
}
require.Nil(t, err)
require.Equal(t, int64(10*1024*1024*1024), s)
}
func TestParseSize_10MUpperCaseSuccess(t *testing.T) {
s, err := ParseSize("10M")
if err != nil {
t.Fatal(err)
}
require.Nil(t, err)
require.Equal(t, int64(10*1024*1024), s)
}
func TestParseSize_10kLowerCaseSuccess(t *testing.T) {
s, err := ParseSize("10k")
if err != nil {
t.Fatal(err)
}
require.Nil(t, err)
require.Equal(t, int64(10*1024), s)
}
func TestParseSize_FailureInvalid(t *testing.T) {
_, err := ParseSize("not a size")
if err == nil {
t.Fatalf("expected error, but got none")
require.Error(t, err)
}
func TestFormatSize(t *testing.T) {
values := []struct {
size int64
expected string
}{
{10, "10"},
{10 * 1024, "10K"},
{10 * 1024 * 1024, "10M"},
{10 * 1024 * 1024 * 1024, "10G"},
}
for _, value := range values {
require.Equal(t, value.expected, FormatSize(value.size))
s, err := ParseSize(FormatSize(value.size))
require.Nil(t, err)
require.Equalf(t, value.size, s, "size does not match: %d != %d", value.size, s)
}
}
func TestFormatSize_Rounded(t *testing.T) {
require.Equal(t, "10K", FormatSize(10*1024+999))
}
func TestSplitKV(t *testing.T) {

2217
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"action_bar_clear_notifications": "Премахване на известия",
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия.",
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия",
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
"notifications_example": "Пример",
"notifications_no_subscriptions_title": "Липсват абонаменти.",
"notifications_no_subscriptions_title": "Липсват абонаменти",
"nav_topics_title": "Абонаменти",
"action_bar_send_test_notification": "Пробно известие",
"action_bar_unsubscribe": "Отписване",
@@ -22,7 +22,7 @@
"publish_dialog_chip_email_label": "Препращане към ел. поща",
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
"publish_dialog_chip_attach_file_label": "Прикачване местен файл",
"publish_dialog_chip_delay_label": "Забавяне на изпращането",
"publish_dialog_chip_delay_label": "Отлагане на изпращането",
"publish_dialog_chip_topic_label": "Промяна на темата",
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
"publish_dialog_button_cancel": "Отказ",
@@ -39,7 +39,7 @@
"prefs_notifications_delete_after_never": "Никога",
"prefs_users_add_button": "Добавяне",
"prefs_users_dialog_password_label": "Парола",
"alert_not_supported_description": "Мрежовият четец не поддържа известия.",
"alert_not_supported_description": "Мрежовият четец не поддържа известия",
"message_bar_type_message": "Въведете съобщение",
"message_bar_error_publishing": "Грешка при изпращане на известието",
"notifications_copied_to_clipboard": "Копирано в междинната памет",
@@ -55,16 +55,16 @@
"notifications_attachment_open_title": "Към {{url}}",
"notifications_attachment_copy_url_button": "Копиране на адреса",
"notifications_attachment_open_button": "Отваряне на прикачения файл",
"notifications_attachment_link_expires": "препратката изтича на {{date}}",
"notifications_attachment_link_expires": "давността на препратката изтича на {{date}}",
"notifications_actions_open_url_title": "Към {{url}}",
"notifications_click_copy_url_button": "Копиране на препратка",
"notifications_click_open_button": "Отваряне",
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
"notifications_none_for_topic_title": "Липсват известия в темата.",
"notifications_none_for_any_title": "Липсват известия.",
"notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса й.",
"notifications_none_for_topic_title": "Темата е все още празна",
"notifications_none_for_any_title": "Липсват известия",
"notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.",
"notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като направите заявка чрез методите PUT или POST ще ги получите тук.",
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете или да се абонирате за тема. След това като изпратите съобщение с методите PUT или POST ще го получите тук.",
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
"publish_dialog_priority_min": "Най-нисък приоритет",
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
@@ -84,14 +84,14 @@
"publish_dialog_topic_label": "Име на темата",
"publish_dialog_title_label": "Заглавие",
"publish_dialog_priority_label": "Приоритет",
"publish_dialog_click_placeholder": "Адрес, който се отваря при щракване върху известието",
"publish_dialog_email_placeholder": "Поща, на която да се препрати известието, напр. phil@example.com",
"publish_dialog_click_placeholder": "Адрес, който се отваря при докосване на известието",
"publish_dialog_email_placeholder": "Адрес, към който да бъдат препращани известия, напр. phil@example.com",
"publish_dialog_attach_label": "Адрес на прикачения файл",
"publish_dialog_filename_placeholder": "Име на прикачения файл",
"publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
"prefs_notifications_delete_after_three_hours": "След три часа",
"publish_dialog_filename_label": "Име на файла",
"publish_dialog_delay_label": "Забавяне",
"publish_dialog_delay_label": "Отлагане",
"publish_dialog_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
"publish_dialog_button_send": "Изпращане",
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
@@ -121,7 +121,7 @@
"subscribe_dialog_login_button_login": "Вход",
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
"prefs_appearance_title": "Външен вид",
"publish_dialog_delay_placeholder": "Забавяне на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
"publish_dialog_delay_placeholder": "Отлагане на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
"prefs_notifications_delete_after_one_week": "След една седмица",
"prefs_users_title": "Управление на потребители",
"prefs_users_table_base_url_header": "Адрес на услугата",
@@ -155,7 +155,7 @@
"notifications_actions_not_supported": "Действието не се поддържа от приложението за интернет",
"action_bar_show_menu": "Показване на менюто",
"action_bar_logo_alt": "Логотип на ntfy",
"action_bar_toggle_mute": "Заглушаване или пускне на известията",
"action_bar_toggle_mute": "Заглушаване или пускане на известията",
"action_bar_toggle_action_menu": "Отваряне или затваряне на менюто с действията",
"nav_button_muted": "Известията са заглушени",
"notifications_list": "Списък с известия",
@@ -177,7 +177,7 @@
"publish_dialog_topic_reset": "Нулиране на тема",
"publish_dialog_click_reset": "Премахване на адрес",
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
"publish_dialog_delay_reset": "Премахва забавянето на изпращането",
"publish_dialog_delay_reset": "Премахва отлагането на изпращането",
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
"emoji_picker_search_clear": "Изчистване на търсенето",
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
@@ -220,7 +220,7 @@
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и писма, по-големи прикачени файлове",
"display_name_dialog_placeholder": "Наименование",
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
@@ -257,7 +257,7 @@
"account_tokens_dialog_button_cancel": "Отказ",
"account_delete_title": "Премахване на профила",
"account_upgrade_dialog_title": "Промяна нивото на профила",
"account_usage_emails_title": "Изпратени съобщения",
"account_usage_emails_title": "Изпратени електронни писма",
"account_usage_reservations_title": "Резервирани теми",
"account_usage_reservations_none": "Няма резервирани теми",
"account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен",
@@ -332,6 +332,76 @@
"account_upgrade_dialog_tier_price_per_month": "на месец",
"account_upgrade_dialog_button_pay_now": "Плащане и абониране",
"account_upgrade_dialog_tier_selected_label": "Избрано",
"account_upgrade_dialog_button_update_subscription": "Премяна на абонамент",
"account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>."
"account_upgrade_dialog_button_update_subscription": "Промяна на абонамент",
"account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>.",
"account_tokens_table_expires_header": "Изтича",
"account_tokens_table_never_expires": "Никога",
"prefs_reservations_title": "Резервирани теми",
"prefs_reservations_table_click_to_subscribe": "Докоснете, за да се абонирате",
"prefs_reservations_dialog_title_delete": "Премахване на резервирането",
"prefs_reservations_table_everyone_read_only": "Аз мога да публикувам и да се абонирам, всички останали могат да се абонират",
"prefs_reservations_table_not_subscribed": "Без абонамент",
"account_tokens_table_token_header": "Код за достъп",
"account_tokens_table_create_token_button": "Създаване на код за достъп",
"account_tokens_dialog_expires_x_days": "Кодът за достъп изтича след {{days}} дена",
"account_tokens_dialog_expires_never": "Кодът за достъп не изтича",
"account_tokens_delete_dialog_title": "Премахване на код за достъп",
"prefs_reservations_limit_reached": "Достигнахте ограничението за брой резервирани теми.",
"prefs_reservations_add_button": "Добавяне на тема",
"prefs_reservations_delete_button": "Нулиране на правата за достъп",
"prefs_reservations_table": "Списък с резервирани теми",
"prefs_reservations_dialog_title_add": "Резервиране на тема",
"prefs_reservations_dialog_title_edit": "Променяне на резервирана тема",
"account_tokens_table_current_session": "Текущ сеанс на четеца",
"account_tokens_table_copied_to_clipboard": "Кодът за достъп е копиран",
"account_tokens_table_cannot_delete_or_edit": "Не можете да променяте или премахвате кода за достъп на текущия сеанс",
"account_tokens_table_last_origin_tooltip": "От адрес по IP {{ip}}, щракнете за подробности",
"account_tokens_dialog_title_create": "Създаване на код за достъп",
"account_tokens_dialog_title_edit": "Променяне на код за достъп",
"account_tokens_dialog_title_delete": "Премахване на код за достъп",
"account_tokens_dialog_label": "Етикет, напр. Известия от Radarr",
"account_tokens_dialog_button_create": "Създаване на код за достъп",
"account_tokens_dialog_button_update": "Променяне на код за достъп",
"account_tokens_dialog_expires_label": "Кодът за достъп изтича след",
"account_tokens_dialog_expires_x_hours": "Кодът за достъп изтича след {{hours}} часа",
"account_tokens_dialog_expires_unchanged": "Без промяна на давността",
"account_tokens_delete_dialog_submit_button": "Безвъзвратно премахване на код за достъп",
"prefs_users_description_no_sync": "Потребителите и паролите не се синхронизират заедно с профила.",
"prefs_users_table_cannot_delete_or_edit": "Влезлият потребител не може да бъде премахнат",
"prefs_reservations_table_everyone_deny_all": "Само аз мога да публикувам и да се абонирам",
"prefs_reservations_table_everyone_write_only": "Аз мога да публикувам и да се абонирам, всички останали могат да публикуват",
"prefs_reservations_table_everyone_read_write": "Всички могат да публикуват и да се абонират",
"reservation_delete_dialog_submit_button": "Премахване на резервирането",
"account_tokens_description": "Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.",
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>",
"prefs_reservations_dialog_description": "Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
"reservation_delete_dialog_action_keep_title": "Пазене на съобщения и прикачени файлове",
"reservation_delete_dialog_action_keep_description": "Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.",
"reservation_delete_dialog_action_delete_title": "Премахване на съобщения и прикачени файлове",
"reservation_delete_dialog_action_delete_description": "Съобщенията и прикачените файлове, които са във временната памет ще бъдат премахнати. Действието е необратимо.",
"prefs_reservations_description": "Тук можете да резервирате тема за собствено ползване. Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
"reservation_delete_dialog_description": "С премахването на резервирането вие се отказвате от собствеността върху темата и давате възможност друг потребител да я резервира. Можете да оставите или да премахнете съществуващите съобщения и прикачени файлове.",
"alert_notification_permission_denied_description": "Включете ги от мрежовия четец",
"alert_notification_permission_denied_title": "Известията са изключени",
"notifications_actions_failed_notification": "Действието е неуспешно",
"publish_dialog_checkbox_markdown": "Съобщението е Markdown",
"prefs_notifications_web_push_disabled_description": "Известията ще бъдат получавани докато приложението за уеб работи (чрез WebSocket)",
"prefs_notifications_web_push_enabled": "Включено за {{server}}",
"prefs_notifications_web_push_disabled": "Изключено",
"prefs_appearance_theme_dark": "Тъмна",
"prefs_appearance_theme_light": "Светла",
"error_boundary_button_reload_ntfy": "Презареждне на ntfy",
"web_push_unknown_notification_title": "Получено е неочаквано известие",
"web_push_unknown_notification_body": "Вероятно ще трябва да обновите ntfy като отворите приложението за уеб",
"alert_notification_ios_install_required_title": "Необходимо е инсталиране за iOS",
"alert_notification_ios_install_required_description": "Докоснете бутона Споделяне и Добавяне към началния екран, за да включите известията под iOS",
"subscribe_dialog_subscribe_use_another_background_info": "Известията от други сървъри няма да бъдат получавани ако приложението за уеб не е отворено",
"action_bar_mute_notifications": "Заглушаване на известия",
"prefs_notifications_web_push_title": "Известия във фонов режим",
"prefs_notifications_web_push_enabled_description": "Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)",
"prefs_appearance_theme_title": "Цветова тема",
"prefs_appearance_theme_system": "Системна (подразбирана)",
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
"action_bar_unmute_notifications": "Включване звука на известията"
}

View File

@@ -1,7 +1,7 @@
{
"common_save": "Gem",
"common_add": "Tilføj",
"signup_title": "Opret en ntfy konto",
"signup_title": "Opret en ntfy-konto",
"signup_form_username": "Brugernavn",
"signup_form_password": "Kodeord",
"signup_form_confirm_password": "Bekræft kodeord",
@@ -10,24 +10,24 @@
"signup_error_username_taken": "Brugernavnet {{username}} er optaget",
"login_form_button_submit": "Log ind",
"action_bar_show_menu": "Vis menu",
"action_bar_logo_alt": "ntfy logo",
"action_bar_logo_alt": "ntfy-logo",
"action_bar_settings": "Indstillinger",
"signup_form_button_submit": "Opret konto",
"signup_form_toggle_password_visibility": "Skift synlighed af adgangskode",
"signup_disabled": "Tilmelding er deaktiveret",
"signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået",
"login_title": "Log ind på din ntfy konto",
"login_title": "Log ind på din ntfy-konto",
"login_link_signup": "Opret konto",
"login_disabled": "Login er deaktiveret",
"action_bar_reservation_add": "Reserver emne",
"action_bar_reservation_edit": "Rediger reservation",
"action_bar_reservation_delete": "Fjern reservation",
"action_bar_reservation_limit_reached": "Grænsen er nået",
"action_bar_send_test_notification": "Send test notifikation",
"action_bar_send_test_notification": "Send testnotifikation",
"action_bar_unsubscribe": "Afmeld",
"action_bar_toggle_mute": "Slå lyden fra/til for notifikationer",
"action_bar_change_display_name": "Skift visningsnavn",
"action_bar_toggle_action_menu": "Åben/luk handlings menu",
"action_bar_toggle_action_menu": "Åben/luk handlingsmenu",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Indstillinger",
"action_bar_profile_logout": "Log ud",
@@ -35,7 +35,7 @@
"action_bar_sign_up": "Opret konto",
"message_bar_type_message": "Skriv en besked her",
"nav_button_settings": "Indstillinger",
"message_bar_publish": "Offentliggør besked",
"message_bar_publish": "Udgiv besked",
"nav_topics_title": "Tilmeldte emner",
"nav_button_all_notifications": "Alle notifikationer",
"nav_button_connecting": "forbinder",
@@ -58,9 +58,9 @@
"notifications_attachment_open_title": "Gå til {{url}}",
"notifications_attachment_open_button": "Åben vedhæftning",
"notifications_attachment_link_expires": "link udløber {{date}}",
"notifications_attachment_link_expired": "download link er udløbet",
"notifications_attachment_link_expired": "download-link er udløbet",
"notifications_attachment_file_image": "billedfil",
"notifications_attachment_file_app": "Android app fil",
"notifications_attachment_file_app": "Android-appfil",
"notifications_attachment_file_document": "andet dokument",
"notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen",
"notifications_click_copy_url_button": "Kopier link",
@@ -103,7 +103,7 @@
"account_basics_tier_free": "Gratis",
"account_basics_tier_admin_suffix_no_tier": "(intet niveau)",
"account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} niveau)",
"account_usage_messages_title": "Offentliggjorte meddelelser",
"account_usage_messages_title": "Udgivne beskeder",
"account_delete_dialog_button_submit": "Slet konto permanent",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pr. fil",
"account_upgrade_dialog_button_redirect_signup": "Tilmeld dig nu",
@@ -279,5 +279,106 @@
"reservation_delete_dialog_action_keep_title": "Behold cachelagrede meddelelser og vedhæftede filer",
"reservation_delete_dialog_action_delete_title": "Slet cachelagrede meddelelser og vedhæftede filer",
"error_boundary_title": "Oh nej, ntfy brød sammen",
"error_boundary_description": "Dette bør naturligvis ikke ske. Det beklager vi meget.<br/>Hvis du har et øjeblik, bedes du <githubLink>rapportere dette på GitHub</githubLink>, eller give os besked via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>."
"error_boundary_description": "Dette bør naturligvis ikke ske. Det beklager vi meget.<br/>Hvis du har et øjeblik, bedes du <githubLink>rapportere dette på GitHub</githubLink>, eller give os besked via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
"account_upgrade_dialog_tier_features_no_calls": "Ingen telefonopkald",
"account_upgrade_dialog_billing_contact_email": "For faktureringsspørgsmål bedes du <Link>kontakte os</Link> direkte.",
"account_basics_tier_interval_monthly": "månedlig",
"publish_dialog_checkbox_publish_another": "Udgiv en anden",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} daglige telefonopkald",
"publish_dialog_filename_placeholder": "Vedhæftet filnavn",
"prefs_users_description": "Tilføj/fjern brugere til dine beskyttede emner her. Vær opmærksom på, at brugernavn og adgangskode er gemt i browserens lokale lager.",
"account_basics_phone_numbers_dialog_number_label": "Telefonnummer",
"subscribe_dialog_subscribe_description": "Emner kan ikke beskyttes med adgangskode, så vælg et navn, der ikke er let at gætte. Når du har abonneret, kan du PUT/POST notifikationer.",
"account_basics_phone_numbers_dialog_check_verification_button": "Bekræft kode",
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "spar op til {{discount}}%",
"account_upgrade_dialog_proration_info": "<strong>Proration</strong>: Når du opgraderer mellem betalte planer, vil prisforskellen blive <strong>opkrævet med det samme</strong>. Ved nedgradering til et lavere niveau, vil saldoen blive brugt til at betale for fremtidige faktureringsperioder.",
"account_usage_attachment_storage_title": "opbevaring af vedhæftede filer",
"message_bar_error_publishing": "Der opstod en fejl under udgivelse af meddelelse",
"publish_dialog_chip_delay_label": "Forsinke leveringen",
"prefs_reservations_table_not_subscribed": "Ikke abonneret",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} daglige telefonopkald",
"account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS",
"prefs_reservations_table_everyone_read_only": "Jeg kan udgive og abonnere, alle kan abonnere",
"prefs_reservations_table_everyone_deny_all": "Kun jeg kan udgive og abonnere",
"publish_dialog_chip_topic_label": "Skift emne",
"account_basics_phone_numbers_dialog_description": "For at bruge opkaldsmeddelelsesfunktionen skal du tilføje og bekræfte mindst ét telefonnummer. Bekræftelse kan gøres via SMS eller et telefonopkald.",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserveret emne",
"account_upgrade_dialog_tier_features_no_reservations": "Ingen reserverede emner",
"publish_dialog_base_url_label": "Tjeneste-URL",
"prefs_users_table_cannot_delete_or_edit": "Kan ikke slette eller redigere en aktiv bruger",
"publish_dialog_title_no_topic": "Udgiv notifikation",
"publish_dialog_attach_label": "URL til vedhæftede filer",
"nav_button_muted": "Notifikationer slået fra",
"prefs_notifications_min_priority_description_x_or_higher": "Vis notifikationer hvis prioritet er {{number}} ({{name}}) eller højere",
"reservation_delete_dialog_description": "Fjernelse af en reservation opgiver ejerskabet over emnet og giver andre mulighed for at reservere det. Du kan beholde eller slette eksisterende beskeder og vedhæftede filer.",
"prefs_reservations_table_everyone_read_write": "Alle kan udgive og abonnere",
"account_upgrade_dialog_interval_monthly": "månedlig",
"account_basics_phone_numbers_no_phone_numbers_yet": "Ingen telefonnumre endnu",
"notifications_no_subscriptions_description": "Klik på linket \"{{linktext}}\" for at oprette eller abonnere på et emne. Derefter kan du sende beskeder via PUT eller POST, og du vil modtage notifikationer her.",
"publish_dialog_message_published": "Notifikation udgivet",
"publish_dialog_chip_call_label": "Telefon opkald",
"account_basics_phone_numbers_dialog_title": "Tilføj telefonnummer",
"account_tokens_delete_dialog_description": "Før du sletter et adgangstoken, skal du sikre dig, at ingen programmer eller scripts aktivt bruger det. <strong>Denne handling kan ikke fortrydes</strong>.",
"account_upgrade_dialog_billing_contact_website": "For spørgsmål om fakturering, se venligst vores <Link>hjemmeside</Link>.",
"account_usage_reservations_none": "Ingen reserverede emner til denne konto",
"account_tokens_description": "Brug adgangstokens, når du udgiver og abonnerer via ntfy API, så du ikke behøver at sende dine kontooplysninger. Tjek <Link>dokumentationen</Link> for at få mere at vide.",
"prefs_reservations_table": "Reserverede emner tabel",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daglig e-mail",
"prefs_reservations_description": "Her kan du reservere emnenavne til personlig brug. Reservering af et emne giver dig ejerskab over emnet og giver dig mulighed for at definere adgangstilladelser for andre brugere over emnet.",
"prefs_users_description_no_sync": "Brugere og adgangskoder er ikke synkroniseret til din konto.",
"nav_button_publish_message": "Udgiv notifikation",
"prefs_users_table_base_url_header": "Tjeneste-URL",
"publish_dialog_attach_reset": "Fjern URL til vedhæftede filer",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} daglig besked",
"account_upgrade_dialog_reservations_warning_one": "Det valgte niveau tillader færre reserverede emner end dit nuværende niveau. Før du ændrer dit niveau, <strong>slet venligst mindst én reservation</strong>. Du kan fjerne reservationer i <Link>Indstillinger</Link>.",
"error_boundary_unsupported_indexeddb_description": "ntfy-webappen har brug for IndexedDB for at fungere, og din browser understøtter ikke IndexedDB i privat browsing-tilstand.<br/><br/>Selv om dette er uheldigt, giver det heller ikke ret meget mening at bruge ntfy-webappen i privat browsing-tilstand alligevel, fordi alt er gemt i browserens lager. Du kan læse mere om det <githubLink>i dette GitHub issue</githubLink>, eller tale med os på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
"publish_dialog_title_placeholder": "Notifikationstitel, f.eks. Advarsel om diskplads",
"account_basics_tier_description": "Din kontos niveau",
"account_basics_phone_numbers_description": "For notifikationer via telefonopkald",
"account_upgrade_dialog_cancel_warning": "Dette vil <strong>annullere dit abonnement</strong> og nedgradere din konto den {{date}}. På den dato <strong>slettes</strong> emnereservationer samt meddelelser, der er gemt på serveren.",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Ingen verificerede telefonnumre",
"publish_dialog_call_label": "Telefon opkald",
"account_usage_calls_title": "Telefonopkald foretaget",
"prefs_notifications_min_priority_description_any": "Viser alle notifikationer, uanset prioritet",
"error_boundary_gathering_info": "Indsaml mere info…",
"reservation_delete_dialog_action_keep_description": "Beskeder og vedhæftede filer, der er cachelagret på serveren, bliver offentligt synlige for personer med kendskab til emnenavnet.",
"account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopieret til udklipsholder",
"prefs_reservations_dialog_description": "Reservering af et emne giver dig ejerskab over emnet og giver dig mulighed for at definere adgangstilladelser for andre brugere over emnet.",
"publish_dialog_title_topic": "Udgiv til {{topic}}",
"account_basics_phone_numbers_dialog_number_placeholder": "f.eks. +4512345678",
"account_basics_phone_numbers_dialog_code_placeholder": "f.eks. 123456",
"account_basics_username_description": "Hej, der er du ❤",
"publish_dialog_base_url_placeholder": "Tjeneste-URL, f.eks. https://example.com",
"account_basics_tier_interval_yearly": "årligt",
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} årligt. Faktureres månedligt.",
"account_basics_phone_numbers_dialog_channel_call": "Opkald",
"publish_dialog_attachment_limits_file_and_quota_reached": "overskrider filgrænsen og kvoten på {{fileSizeLimit}}, {{remainingBytes}} tilbage",
"account_upgrade_dialog_interval_yearly": "årligt",
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} faktureres årligt. Spar {{save}}.",
"account_usage_basis_ip_description": "Brugsstatistikker og begrænsninger for denne konto er baseret på din IP-adresse, så de kan være delt med andre brugere. Ovenstående grænser er omtrentlige baseret på de eksisterende hastigheds grænser.",
"account_basics_password_dialog_title": "Skift kodeord",
"account_basics_phone_numbers_title": "Telefonnumre",
"account_upgrade_dialog_interval_yearly_discount_save": "spar {{discount}}%",
"publish_dialog_drop_file_here": "Smid filen her",
"prefs_reservations_table_everyone_write_only": "Jeg kan udgive og abonnere, alle kan udgive",
"account_tokens_table_cannot_delete_or_edit": "Kan ikke redigere eller slette nuværende sessionstoken",
"publish_dialog_attached_file_filename_placeholder": "Vedhæftet filnavn",
"subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
"account_upgrade_dialog_tier_price_per_month": "måned",
"message_bar_show_dialog": "Vis udgivelsesdialogen",
"account_usage_calls_none": "Der kan ikke foretages telefonopkald med denne konto",
"nav_upgrade_banner_description": "Reserver emner, flere beskeder og e-mails og større vedhæftede filer",
"publish_dialog_call_reset": "Fjern telefon opkald",
"account_basics_phone_numbers_dialog_code_label": "Verifikationskode",
"reservation_delete_dialog_action_delete_description": "Cachelagrede beskeder og vedhæftede filer slettes permanent. Denne handling kan ikke fortrydes.",
"alert_grant_button": "Tillad nu",
"account_usage_attachment_storage_description": "{{filesize}} pr. fil, slettet efter {{expiry}}",
"publish_dialog_chip_click_label": "Klik på URL",
"account_basics_phone_numbers_dialog_verify_button_call": "Ring til mig",
"publish_dialog_call_item": "Ring til tlf. {{number}}",
"prefs_users_dialog_base_url_label": "Tjeneste-URL, f.eks. https://ntfy.sh",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_delete_dialog_billing_warning": "Hvis du sletter din konto, så annulleres dit abonnement med det samme. Du vil ikke længere have adgang til faktureringspanelet.",
"prefs_notifications_min_priority_description_max": "Vis notifikationer, hvis prioritet er 5 (maks.)",
"account_upgrade_dialog_reservations_warning_other": "Det valgte niveau tillader færre reserverede emner end dit nuværende niveau. Før du ændrer dit niveau, <strong>slet venligst mindst {{count}} reservationer</strong>. Du kan fjerne reservationer i <Link>Indstillinger</Link>."
}

View File

@@ -25,7 +25,7 @@
"notifications_click_copy_url_title": "Link-URL in Zwischenablage kopieren",
"publish_dialog_priority_low": "Niedrige Priorität",
"publish_dialog_message_label": "Nachricht",
"action_bar_unsubscribe": "Abmelden",
"action_bar_unsubscribe": "Abbestellen",
"notifications_copied_to_clipboard": "In Zwischenablage kopiert",
"notifications_loading": "Benachrichtigungen werden geladen …",
"notifications_attachment_open_title": "Gehe zu {{url}}",
@@ -82,7 +82,7 @@
"publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_placeholder": "Dateiname des Anhangs",
"publish_dialog_delay_label": "Verzögerung",
"publish_dialog_email_placeholder": "E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z. B. phil@example.com",
"publish_dialog_email_placeholder": "E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z.B. phil@example.com",
"publish_dialog_chip_click_label": "Klick-URL",
"publish_dialog_button_cancel_sending": "Senden abbrechen",
"publish_dialog_drop_file_here": "Datei hierher ziehen",
@@ -180,7 +180,7 @@
"error_boundary_unsupported_indexeddb_description": "Die ntfy Web-App benötigt eine IndexedDB für eine korrekte Funktion, und Dein Browser unterstützt in privaten Tabs keinen IndexedDB.<br/><br/>Das ist zwar ärgerlich, eine Nutzung von ntfy in einem privaten Tab macht aber auch wenig Sinn da alle Daten im Browser gespeichert werden. Weitere Informationen gibt es <githubLink>in diesem GitHub-Issue</githubLink>, oder im Chat bei <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.",
"action_bar_toggle_action_menu": "Aktionsmenü öffnen/schließen",
"notifications_new_indicator": "Neue Benachrichtigung",
"publish_dialog_email_reset": "Email-Weiterleitung entfernen",
"publish_dialog_email_reset": "E-Mail-Weiterleitung entfernen",
"action_bar_logo_alt": "ntfy Logo",
"nav_button_muted": "Benachrichtigungen stummgeschaltet",
"notifications_list_item": "Benachrichtigung",
@@ -217,7 +217,7 @@
"signup_form_password": "Kennwort",
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
"nav_button_account": "Konto",
"nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & Emails, größere Anhänge",
"nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & E-Mails und größere Anhänge",
"display_name_dialog_title": "Anzeigennamen ändern",
"display_name_dialog_placeholder": "Anzeigename",
"reserve_dialog_checkbox_label": "Thema reservieren und Zugriffsrechte konfigurieren",
@@ -245,7 +245,7 @@
"account_basics_tier_payment_overdue": "Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.",
"account_basics_tier_manage_billing_button": "Zahlung verwalten",
"account_usage_messages_title": "Veröffentlichte Nachrichten",
"account_usage_emails_title": "Gesendete Emails",
"account_usage_emails_title": "Gesendete E-Mails",
"account_usage_reservations_title": "Reservierte Themen",
"account_usage_reservations_none": "Keine reservierten Themen für dieses Konto",
"account_usage_attachment_storage_title": "Speicherplatz für Anhänge",
@@ -266,7 +266,7 @@
"account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reservierte Themen",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} Nachrichten pro Tag",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} Emails pro Tag",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} E-Mails pro Tag",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz",
"account_upgrade_dialog_tier_selected_label": "Ausgewählt",

View File

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

View File

@@ -8,7 +8,7 @@
"message_bar_type_message": "Escriba un mensaje aquí",
"message_bar_error_publishing": "Error al publicar la notificación",
"alert_notification_permission_required_title": "Las notificaciones están deshabilitadas",
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.",
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones de escritorio",
"nav_button_all_notifications": "Todas las notificaciones",
"nav_button_settings": "Ajustes",
"nav_button_subscribe": "Suscribirse al tópico",
@@ -16,7 +16,7 @@
"nav_button_publish_message": "Publicar notificación",
"notifications_copied_to_clipboard": "Copiado al portapapeles",
"alert_not_supported_title": "Notificaciones no soportadas",
"alert_not_supported_description": "Las notificaciones no están soportadas por tu navegador.",
"alert_not_supported_description": "Su navegador no admite notificaciones",
"notifications_tags": "Etiquetas",
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
"notifications_attachment_copy_url_button": "Copiar URL",
@@ -381,5 +381,28 @@
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados"
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados",
"action_bar_mute_notifications": "Silenciar Notificaciones",
"action_bar_unmute_notifications": "Reactivar notificaciones",
"alert_notification_permission_denied_title": "Notificaciones bloqueadas",
"alert_notification_permission_denied_description": "Porfavor, reactivelas en su navegador",
"alert_notification_ios_install_required_title": "Requiere instalacion de iOS",
"alert_notification_ios_install_required_description": "Haz click en el icono de compartir y Añadir a pantalla de inicio para activar las notificaciones de iOS",
"notifications_actions_failed_notification": "Acción fallida",
"publish_dialog_checkbox_markdown": "Formatear como Markdown",
"subscribe_dialog_subscribe_use_another_background_info": "Las notificaciones de otros servidores no se recibirán cuando la aplicación web no esté abierta",
"prefs_notifications_web_push_title": "Notificaciones en segundo plano",
"prefs_notifications_web_push_enabled_description": "Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)",
"prefs_notifications_web_push_disabled": "Desactivado",
"prefs_appearance_theme_title": "Tema",
"prefs_appearance_theme_system": "Sistema (por defecto)",
"error_boundary_button_reload_ntfy": "Volver a cargar ntfy",
"web_push_subscription_expiring_title": "Las notificaciones se pausarán",
"prefs_notifications_web_push_disabled_description": "Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)",
"prefs_notifications_web_push_enabled": "Activado para {{server}}",
"prefs_appearance_theme_light": "Claro",
"prefs_appearance_theme_dark": "Oscuro",
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibiendo notificaciones",
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web"
}

View File

@@ -20,7 +20,7 @@
"prefs_users_description": "Lisää/poista käyttäjiä suojatuista topikeista täällä. Huomaa, että käyttäjätunnus ja salasana on tallennettu selaimen paikalliseen tallennustilaan.",
"account_basics_phone_numbers_dialog_number_label": "Puhelinnumero",
"subscribe_dialog_subscribe_description": "Aiheet eivät välttämättä ole salasanasuojattuja, joten valitse nimi, jota ei ole helposti arvatavissa. Kun olet tilannut, voit käyttää PUT/POST ilmoituksia.",
"action_bar_logo_alt": "ntfy logo",
"action_bar_logo_alt": "ntfy-logo",
"account_basics_password_dialog_button_submit": "Vaihda salasana",
"publish_dialog_emoji_picker_show": "Valitse emoji",
"account_basics_username_title": "Käyttäjätunnus",
@@ -30,7 +30,7 @@
"account_tokens_dialog_label": "Etiketti, esim. Tutka-ilmoitukset",
"common_add": "Lisää",
"account_tokens_table_expires_header": "Vanhenee",
"account_upgrade_dialog_proration_info": "<strong>Osuus suhde</strong>: Kun päivität maksullisten pakettien välillä, hintaero <strong>veloitetaan välittömästi</strong>. Kun siirryt alemmalle tasolle, saldoa käytetään tulevien laskutuskausien maksamiseen.",
"account_upgrade_dialog_proration_info": "<strong>Osuussuhde</strong>: Kun päivität maksullisten pakettien välillä, hintaero <strong>veloitetaan välittömästi</strong>. Kun siirryt alemmalle tasolle, saldoa käytetään tulevien laskutuskausien maksamiseen.",
"prefs_reservations_dialog_access_label": "Oikeudet",
"account_usage_attachment_storage_title": "Liiteiden säilytys",
"prefs_users_dialog_username_label": "Username, esim pena",
@@ -42,9 +42,9 @@
"prefs_reservations_table_not_subscribed": "Ei tilattu",
"publish_dialog_topic_placeholder": "Topikin nimi, esim. erkin_hälyt",
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} päivittäisiä emaileja",
"prefs_notifications_min_priority_max_only": "Vain maksimi prioriteetti",
"prefs_notifications_min_priority_max_only": "Vain maksimiprioriteetti",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} päivittäisiä puheluja",
"prefs_notifications_sound_description_some": "Ilmoitukset soittavat {{sound}} äänen saapuessaan",
"prefs_notifications_sound_description_some": "Ilmoitukset soittavat {{sound}}-äänen saapuessaan",
"prefs_reservations_edit_button": "Muokkaa topikin oikeuksia",
"account_basics_phone_numbers_dialog_verify_button_sms": "Lähetä SMS",
"account_basics_tier_change_button": "Vaihda",
@@ -60,7 +60,7 @@
"publish_dialog_tags_placeholder": "Pilkuilla eroteltu luettelo tunnisteista, esim. varoitus, srv1-varmuuskopio",
"account_delete_title": "Poista tili",
"publish_dialog_attached_file_remove": "Poista liitetiedosto",
"nav_button_connecting": "yhdistää",
"nav_button_connecting": "yhdistetään",
"account_delete_dialog_label": "Salasana",
"subscribe_dialog_login_button_login": "Kirjaudu",
"account_upgrade_dialog_tier_features_no_reservations": "Ei varattuja topikkeja",
@@ -84,19 +84,19 @@
"subscribe_dialog_error_user_not_authorized": "Käyttäjää {{username}} ei ole valtuutettu",
"prefs_reservations_table_everyone_read_write": "Jokainen voi julkaista ja tilata",
"prefs_reservations_dialog_title_delete": "Poista topikin varaus",
"prefs_users_table": "Käyttäjä taulukko",
"prefs_users_table": "Käyttäjätaulukko",
"prefs_reservations_table_topic_header": "Topikki",
"action_bar_toggle_mute": "Hiljennä/poista hiljennys",
"reservation_delete_dialog_submit_button": "Poista varaus",
"account_basics_title": "Tili",
"nav_button_documentation": "Käyttäjä oppaat",
"nav_button_documentation": "Dokumentointi",
"prefs_reservations_limit_reached": "Olet saavuttanut varattujen topikkien rajan.",
"account_upgrade_dialog_interval_monthly": "Kuukausittain",
"prefs_users_add_button": "Lisää käyttäjä",
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} päivittäisiä viestejä",
"publish_dialog_delay_reset": "Poista viivästetty toimitus",
"account_basics_phone_numbers_no_phone_numbers_yet": "Ei puhelinnumeroita vielä",
"action_bar_toggle_action_menu": "Avaa/sulje toiminto valikko",
"action_bar_toggle_action_menu": "Avaa/sulje toimintovalikko",
"subscribe_dialog_subscribe_button_generate_topic_name": "Luo nimi",
"notifications_list_item": "Ilmoitus",
"prefs_appearance_language_title": "Kieli",
@@ -116,10 +116,10 @@
"account_tokens_table_label_header": "Merkki",
"notifications_attachment_file_document": "muu asiakirja",
"publish_dialog_button_cancel": "Peruuta",
"account_upgrade_dialog_billing_contact_website": "Laskutukseen liittyvissä kysymyksissä käy <Link>website</Link>.",
"account_upgrade_dialog_billing_contact_website": "Laskutukseen liittyvissä kysymyksissä käy sivulla <Link>website</Link>.",
"signup_form_button_submit": "Kirjaudu linkki",
"account_basics_username_admin_tooltip": "Olet pääkäyttäjä",
"prefs_notifications_delete_after_never_description": "Ilmoituksia eivät koskaan poisteta automaattisesti",
"prefs_notifications_delete_after_never_description": "Ilmoituksia ei koskaan poisteta automaattisesti",
"account_delete_dialog_description": "Tämä poistaa pysyvästi tilisi, mukaan lukien kaikki palvelimelle tallennetut tiedot. Poistamisen jälkeen käyttäjätunnuksesi on poissa käytöstä 7 päivään. Jos todella haluat jatkaa, vahvista salasanasi alla olevaan kenttään.",
"publish_dialog_email_reset": "Poista sähköpostin edelleenlähetys",
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} varatut topikit",
@@ -139,11 +139,11 @@
"prefs_reservations_description": "Voit varata topikien nimiä henkilökohtaiseen käyttöön täältä. Aiheen varaaminen antaa sinulle topikin omistajuuden ja voit määrittää topikkiin liittyviä käyttöoikeuksia muille käyttäjille.",
"notifications_attachment_copy_url_title": "Kopioi liitteen URL-osoite leikepöydälle",
"account_usage_title": "Käytössä",
"account_basics_tier_upgrade_button": "Päivitä Pro versioon",
"account_basics_tier_upgrade_button": "Päivitä Pro-versioon",
"prefs_users_description_no_sync": "Käyttäjiä ja salasanoja ei ole synkronoitu tiliisi.",
"account_tokens_dialog_title_edit": "Muokkaa käyttöoikeustunnusta",
"nav_button_publish_message": "Julkaisutiedot",
"prefs_users_table_base_url_header": "Palvelin URL",
"nav_button_publish_message": "Julkaise ilmoitus",
"prefs_users_table_base_url_header": "Palvelin-URL",
"notifications_click_copy_url_title": "Kopioi linkin URL-osoite leikepöydälle",
"publish_dialog_attach_reset": "Poista liitteen URL-osoite",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} päivittäisiä viestejä",
@@ -160,7 +160,7 @@
"publish_dialog_priority_low": "Matala tärkeys",
"publish_dialog_priority_label": "Prioriteetti",
"prefs_reservations_delete_button": "Poista topikin oikeudet",
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
"account_basics_tier_admin_suffix_no_tier": "(ei tasoa)",
"prefs_notifications_delete_after_one_week_description": "Ilmoitukset poistetaan automaattisesti viikon kuluttua",
"error_boundary_unsupported_indexeddb_description": "Ntfy-verkkosovellus tarvitsee IndexedDB:n toimiakseen, eikä selaimesi tue IndexedDB:tä yksityisessä selaustilassa.<br/><br/>Vaikka tämä on valitettavaa, ntfy-verkon käyttäminen ei myöskään ole kovin järkevää yksityisessä selaustilassa, koska kaikki on tallennettu selaimen tallennustilaan. Voit lukea siitä lisää <githubLink>tästä GitHub-numerosta</githubLink> tai puhua meille <discordLink>Discordissa</discordLink> tai <matrixLink>Matrixissa</matrixLink>.",
"subscribe_dialog_subscribe_button_cancel": "Peruuta",
@@ -175,7 +175,7 @@
"notifications_click_copy_url_button": "Kopioi linkki",
"account_basics_tier_admin": "Admin",
"subscribe_dialog_subscribe_title": "Tilaa topikki",
"nav_topics_title": "Tilatut topikit",
"nav_topics_title": "Tilatut aiheet",
"prefs_notifications_sound_title": "Ilmoitusääni",
"prefs_notifications_min_priority_default_and_higher": "Oletusprioriteetti ja korkeammat",
"prefs_reservations_table_access_header": "Oikeudet",
@@ -197,7 +197,7 @@
"account_usage_calls_title": "Soitetut puhelut",
"error_boundary_description": "Näin ei selvästikään pitäisi tapahtua. Pahoittelut tästä.<br/>Jos sinulla on hetki aikaa, <githubLink>ilmoita tästä GitHubissa</githubLink> tai ilmoita meille <discordLink>Discordin</discordLink> tai <matrixLink>Matrix</matrixLink> kautta.",
"signup_form_toggle_password_visibility": "Vaihda salasanan näkyvyys",
"login_link_signup": "Kirjaudu linkki",
"login_link_signup": "Kirjautumislinkki",
"publish_dialog_message_label": "Viesti",
"publish_dialog_attached_file_title": "Liitetiedosto:",
"priority_min": "min",
@@ -232,7 +232,7 @@
"account_basics_username_description": "Hei, se olet sinä ❤",
"prefs_reservations_dialog_topic_label": "Topik",
"account_basics_password_dialog_confirm_password_label": "Vahvista salasana",
"action_bar_reservation_edit": "Muokkaa varatopikkia",
"action_bar_reservation_edit": "Muokkaa varausta",
"publish_dialog_base_url_placeholder": "Palvelun URL-osoite, esim. https://example.com",
"prefs_users_title": "Hallinnoi käyttäjiä",
"account_basics_tier_interval_yearly": "vuosittain",
@@ -247,20 +247,20 @@
"publish_dialog_delay_label": "Viive",
"error_boundary_button_copy_stack_trace": "Kopioi pinon jälki",
"publish_dialog_button_send": "Lähetä",
"action_bar_reservation_delete": "Poista varatopikit",
"action_bar_reservation_delete": "Poista varaus",
"publish_dialog_button_cancel_sending": "Peruuta lähetys",
"account_tokens_dialog_title_delete": "Poista käyttöoikeustunnus",
"account_usage_of_limit": "limiitistä {{limit}}",
"publish_dialog_attach_placeholder": "Liitä tiedosto URL-osoitteen mukaan, esim. https://f-droid.org/F-Droid.apk",
"publish_dialog_email_placeholder": "Osoite, johon ilmoitus välitetään, esim. urpo@example.com",
"notifications_attachment_link_expires": "linkki vanhenee {{date}}",
"action_bar_send_test_notification": "Lähetä testi ilmoitus",
"action_bar_send_test_notification": "Lähetä testi-ilmoitus",
"reservation_delete_dialog_action_keep_title": "Säilytä välimuistissa olevat viestit ja liitteet",
"prefs_notifications_sound_no_sound": "Ei ääntä",
"account_upgrade_dialog_interval_yearly": "Vuosittain",
"publish_dialog_tags_label": "Tagit",
"signup_form_password": "Salasana",
"action_bar_reservation_limit_reached": "Varatopikien raja",
"action_bar_reservation_limit_reached": "Raja saavutettu",
"account_upgrade_dialog_button_redirect_signup": "Kirjaudu nyt",
"publish_dialog_click_placeholder": "URL-osoite, joka avautuu, kun ilmoitusta napsautetaan",
"alert_not_supported_title": "Ilmoituksia ei tueta",
@@ -300,28 +300,28 @@
"account_tokens_table_cannot_delete_or_edit": "Nykyistä istuntotunnusta ei voi muokata tai poistaa",
"notifications_tags": "Tagit",
"prefs_notifications_sound_play": "Toista valittu ääni",
"account_tokens_table_last_access_header": "Viimeinen käyty",
"account_tokens_table_last_access_header": "Viimeinen käynti",
"action_bar_profile_logout": "Kirjaudu ulos",
"publish_dialog_attached_file_filename_placeholder": "Liitetiedoston nimi",
"publish_dialog_priority_default": "Oletusprioriteetti",
"subscribe_dialog_subscribe_base_url_label": "Palvelimen URL",
"account_tokens_table_last_origin_tooltip": "Napsauta IP-osoitteesta {{ip}}, etsiäksesi",
"account_tokens_table_last_origin_tooltip": "Napsauta IP-osoitteesta {{ip}} etsiäksesi",
"account_usage_reservations_title": "Varatut topikit",
"account_upgrade_dialog_tier_price_per_month": "Kuukausi",
"message_bar_show_dialog": "Näytä julkaisu dialogi",
"message_bar_show_dialog": "Näytä julkaisudialogi",
"publish_dialog_chip_attach_url_label": "Liitä tiedosto URL-osoitteen mukaan",
"account_usage_calls_none": "Tällä tilillä ei voi soittaa puheluita",
"notifications_click_open_button": "Avaa linkki",
"account_tokens_table_current_session": "Nykyinen selainistunto",
"account_upgrade_dialog_button_pay_now": "Maksa nyt ja tilaa",
"nav_upgrade_banner_description": "Varaa aiheita, lisää viestejä ja sähköposteja sekä suurempia liitteitä",
"nav_upgrade_banner_description": "Varaa aiheita, lisää viestejä ja sähköposteja, sekä suurempia liitteitä",
"publish_dialog_call_reset": "Poista puhelu",
"publish_dialog_other_features": "Muut ominaisuudet:",
"subscribe_dialog_subscribe_use_another_label": "Käytä toista palvelinta",
"reservation_delete_dialog_action_delete_title": "Poista välimuistissa olevat viestit ja liitteet",
"signup_error_username_taken": "Käyttäjätunnus {{username}} on jo varattu",
"account_basics_phone_numbers_dialog_code_label": "Vahvistuskoodi",
"nav_button_subscribe": "Tilaa topik",
"nav_button_subscribe": "Tilaa aihe",
"publish_dialog_topic_label": "Topikin nimi",
"reservation_delete_dialog_action_delete_description": "Välimuistissa olevat viestit ja liitteet poistetaan pysyvästi. Tätä toimintoa ei voi kumota.",
"alert_grant_button": "Myönnä nyt",
@@ -331,7 +331,7 @@
"prefs_notifications_delete_after_one_month_description": "Ilmoitukset poistetaan automaattisesti kuukauden kuluttua",
"common_cancel": "Peruuta",
"account_basics_phone_numbers_dialog_verify_button_call": "Soita minulle",
"signup_already_have_account": "Onko sinulla jo tili ? Kirjaudu sisään !",
"signup_already_have_account": "Onko sinulla jo tili? Kirjaudu sisään!",
"publish_dialog_call_item": "Soita puhelinnumeroon {{number}}",
"nav_button_account": "Tili",
"publish_dialog_click_reset": "Poista napsautettava URL-osoite",
@@ -341,7 +341,7 @@
"prefs_users_dialog_base_url_label": "Palvelin URL, esim. https://ntfy.sh",
"account_usage_emails_title": "Sähköpostit lähetetty",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"action_bar_reservation_add": "Varalla oleva topikki",
"action_bar_reservation_add": "Varalla oleva aihe",
"account_upgrade_dialog_tier_selected_label": "Valittu",
"account_upgrade_dialog_button_update_subscription": "Päivitä tilaus",
"notifications_attachment_file_video": "videotiedosto",
@@ -349,7 +349,7 @@
"notifications_priority_x": "Prioriteetti {{priority}}",
"account_delete_dialog_billing_warning": "Tilin poistaminen peruuttaa myös laskutustilauksesi välittömästi. Et voi enää käyttää laskutuksen hallintapaneelia.",
"prefs_notifications_min_priority_description_max": "Näytä ilmoitukset, jos prioriteetti on 5 (max)",
"subscribe_dialog_login_description": "Tämä Topikki on suojattu salasanalla. Anna käyttäjätunnus ja salasana.",
"subscribe_dialog_login_description": "Tämä topikki on suojattu salasanalla. Anna käyttäjätunnus ja salasana.",
"account_upgrade_dialog_reservations_warning_other": "Valittu taso sallii vähemmän varattuja topikkeja kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään {{count}} varausta</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.",
"prefs_users_dialog_title_add": "Lisää käyttäjä",
"account_tokens_dialog_button_create": "Luo tunnus",
@@ -360,9 +360,25 @@
"notifications_actions_not_supported": "Toimintoa ei tueta verkkosovelluksessa",
"notifications_actions_open_url_title": "Siirry osoitteeseen {{url}}",
"notifications_none_for_any_title": "Et ole saanut ilmoituksia.",
"notifications_none_for_topic_description": "Jos haluat lähettää ilmoituksia tähän topikkiin, PUT tai POST topikin URL-osoitteeseen.",
"notifications_none_for_topic_description": "Jos haluat lähettää ilmoituksia tähän topikkiin, lähetä PUT tai POST topikin URL-osoitteeseen.",
"notifications_none_for_any_description": "Jos haluat lähettää ilmoituksia topikkiin, PUT tai POST topikin URL-osoitteeseen. Tässä on esimerkki yhden topikin käyttämisestä.",
"notifications_no_subscriptions_title": "Näyttää siltä, että sinulla ei ole vielä tilauksia.",
"notifications_none_for_topic_title": "Et ole vielä saanut ilmoituksia tästä topikista.",
"notifications_actions_http_request_title": "Lähetä HTTP {{method}} to {{url}}"
"notifications_none_for_topic_title": "Et ole vielä saanut ilmoituksia tästä aiheesta.",
"notifications_actions_http_request_title": "Lähetä HTTP {{method}} osoitteeseen {{url}}",
"reserve_dialog_checkbox_label": "Käänteinen aihe ja aseta pääsy",
"publish_dialog_progress_uploading": "Lähetetään …",
"publish_dialog_title_no_topic": "Julkaise ilmoitus",
"notifications_example": "Esimerkki",
"notifications_loading": "Ladataan ilmoituksia …",
"notifications_no_subscriptions_description": "Klikkaa \"{{linktext}}\" linkkiä luodaksesi tai tilataksesi aihe. Sen jälkeen voit lähettää viestejä PUT tai POST metodeilla ja saat ilmoituksesi täällä.",
"display_name_dialog_description": "Aseta vaihtoehtoinen nimi aiheelle, joka on näytetty tilaus-listassa. Tämä auttaa tunnistamaan aiheet helpommin, joilla on hankalat nimet.",
"publish_dialog_message_published": "Ilmoitus julkaistu",
"notifications_more_details": "Saadaksesi lisää tietoa, katso <websiteLink>nettisivu</websiteLink> tai <docsLink>documentointi</docsLink>.",
"publish_dialog_attachment_limits_quota_reached": "ylittää kiintiön, {{remainingBytes}} jäljellä",
"publish_dialog_title_topic": "Julkaise aiheeseen {{topic}}",
"display_name_dialog_placeholder": "Näyttönimi",
"publish_dialog_attachment_limits_file_and_quota_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan ja määrän, {{remainingBytes}} jäljellä",
"publish_dialog_attachment_limits_file_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan",
"publish_dialog_progress_uploading_detail": "Lähetetään {{loaded}}/{{total}} ({{percent}}%) …",
"display_name_dialog_title": "Vaihda näyttönimi"
}

View File

@@ -51,7 +51,7 @@
"nav_button_muted": "Notificacións acaladas",
"nav_button_connecting": "conectando",
"nav_upgrade_banner_label": "Mellorar a ntfy Pro",
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións.",
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións",
"notifications_priority_x": "Prioridade {{priority}}",
"notifications_attachment_link_expires": "a ligazón caduca o {{date}}",
"notifications_attachment_link_expired": "a ligazón de descarga caducou",
@@ -380,5 +380,31 @@
"account_basics_phone_numbers_dialog_verify_button_call": "Chámame",
"account_usage_emails_title": "Emails enviados",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse."
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse.",
"action_bar_mute_notifications": "Acalar notificacións",
"action_bar_unmute_notifications": "Reactivar notificacións",
"alert_notification_permission_required_title": "Notificacións desactivadas",
"alert_notification_permission_required_description": "Concederlle permisos ao navegador para mostrar notificacións de escritorio",
"alert_notification_permission_required_button": "Conceder",
"alert_notification_permission_denied_title": "Notificacións bloqueadas",
"alert_notification_permission_denied_description": "Por favor reactívaas no navegador",
"alert_notification_ios_install_required_title": "Require instalación iOS",
"alert_notification_ios_install_required_description": "Preme na icona Compartir e Engadir a Pantalla de Inicio para activar as notificacións en iOS",
"notifications_actions_failed_notification": "Non se puido realizar a acción",
"publish_dialog_checkbox_markdown": "Dar formato Markdow",
"prefs_notifications_web_push_title": "Notificacións en segundo plano",
"prefs_notifications_web_push_enabled_description": "Recíbense notificacións incluso se a app web non está en execución (vía Web Push)",
"prefs_notifications_web_push_disabled_description": "Recíbense as notificacións cando a app web está en execución (vía WebSocket)",
"prefs_notifications_web_push_enabled": "Activadas para {{server}}",
"prefs_notifications_web_push_disabled": "Desactivadas",
"prefs_appearance_theme_title": "Decorado",
"prefs_appearance_theme_system": "Sistema (por defecto)",
"prefs_appearance_theme_dark": "Modo escuro",
"prefs_appearance_theme_light": "Modo claro",
"error_boundary_button_reload_ntfy": "Recargar ntfy",
"web_push_subscription_expiring_title": "Vanse pausar as notificacións",
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
"web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor",
"web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web",
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada"
}

View File

@@ -381,5 +381,28 @@
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi",
"publish_dialog_call_item": "Panggil nomor telepon {{number}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi"
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi",
"action_bar_unmute_notifications": "Nyalakan notifikasi",
"alert_notification_permission_denied_title": "Notifikasi sedang diblokir",
"alert_notification_permission_denied_description": "Silakan aktifkan lagi dalam peramban Anda",
"alert_notification_ios_install_required_title": "Pemasangan iOS diperlukan",
"alert_notification_ios_install_required_description": "Klik ikon Bagikan dan Tambahkan ke Layar Beranda untuk mengaktifkan notifikasi di iOS",
"notifications_actions_failed_notification": "Tindakan tidak berhasil",
"publish_dialog_checkbox_markdown": "Format sebagai Markdown",
"prefs_notifications_web_push_title": "Notifikasi latar belakang",
"prefs_notifications_web_push_enabled_description": "Notifikasi diterima bahkan ketika aplikasi web tidak berjalan (melalui Web Push)",
"prefs_notifications_web_push_disabled_description": "Notifikasi diterima ketika aplikasi web berjalan (melalui WebSocket)",
"prefs_appearance_theme_title": "Tema",
"error_boundary_button_reload_ntfy": "Muat ulang ntfy",
"action_bar_mute_notifications": "Matikan notifikasi",
"subscribe_dialog_subscribe_use_another_background_info": "Notifikasi dari server lain tidak akan diterima ketika aplikasi web tidak buka",
"prefs_notifications_web_push_enabled": "Diaktifkan untuk {{server}}",
"prefs_notifications_web_push_disabled": "Dinonaktifkan",
"prefs_appearance_theme_dark": "Mode gelap",
"prefs_appearance_theme_system": "Sistem (bawaan)",
"prefs_appearance_theme_light": "Mode terang",
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web"
}

View File

@@ -189,7 +189,7 @@
"prefs_notifications_delete_after_three_hours_description": "Le notifiche vengono eliminate automaticamente dopo tre ore",
"error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.<br/><br/>Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo <githubLink>in questo numero di GitHub</githubLink> o parlarci su <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
"nav_upgrade_banner_label": "Passa alla versione Pro di ntfy",
"alert_not_supported_context_description": "Le Notificche sono supportate solo tramite HTTPS. Questa è una limitazione delle <mdnLink>Notifications API</mdnLink>.",
"alert_not_supported_context_description": "Le Notifiche sono supportate solo tramite HTTPS. Questa è una limitazione delle <mdnLink>Notifications API</mdnLink>.",
"account_basics_password_dialog_new_password_label": "Nuova password",
"action_bar_profile_logout": "Esci",
"account_basics_tier_interval_monthly": "mensile",

View File

@@ -74,7 +74,7 @@
"publish_dialog_email_placeholder": "Adresse å videresende merknaden til, f.eks. phil@example.com",
"error_boundary_gathering_info": "Hent mer info …",
"prefs_notifications_sound_description_some": "Merknader spiller {{sound}}-lyd når de mottas",
"prefs_notifications_min_priority_description_any": "Viser aller merknader, uavhengig av prioritet",
"prefs_notifications_min_priority_description_any": "Viser alle merknader, uavhengig av prioritet",
"prefs_notifications_min_priority_description_x_or_higher": "Vis merknader hvis prioritet er {{number}} ({{name}}) eller høyere",
"prefs_notifications_min_priority_high_and_higher": "Høy prioritet og høyere",
"prefs_notifications_min_priority_max_only": "Kun maks. prioritet",
@@ -158,7 +158,7 @@
"prefs_notifications_min_priority_low_and_higher": "Lav prioritet og høyere",
"prefs_users_description": "Legg til/fjern brukere for dine beskyttede emner her. Vær oppmerksom på at brukernavn og passord er lagret i nettleserens lokale lagring.",
"error_boundary_description": "Dette skal åpenbart ikke skje. Beklager dette.<br/>Hvis du har et minutt, vennligst <githubLink>rapporter dette på GitHub</githubLink>, eller gi oss beskjed via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
"action_bar_logo_alt": "ntfy logo",
"action_bar_logo_alt": "ntfy-logo",
"message_bar_publish": "Publiser melding",
"action_bar_toggle_action_menu": "Åpne/lukk handlingsmeny",
"message_bar_show_dialog": "Vis publiseringsdialog",
@@ -181,7 +181,7 @@
"publish_dialog_topic_reset": "Tilbakestill emne",
"publish_dialog_click_label": "Klikk URL",
"publish_dialog_email_reset": "Fjern videresending av e-post",
"publish_dialog_attach_reset": "Fjern URL vedlegg",
"publish_dialog_attach_reset": "Fjern URL-vedlegg",
"publish_dialog_delay_reset": "Fjern forsinket levering",
"publish_dialog_attached_file_remove": "Fjern vedlagt fil",
"subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
@@ -191,7 +191,7 @@
"action_bar_account": "Konto",
"action_bar_profile_settings": "Innstillinger",
"nav_button_account": "Konto",
"signup_title": "Opprett en ntfy konto",
"signup_title": "Opprett en ntfy-konto",
"signup_form_username": "Brukernavn",
"signup_form_password": "Passord",
"signup_form_button_submit": "Meld deg på",

View File

@@ -317,5 +317,43 @@
"account_upgrade_dialog_tier_features_emails_many": "{{emails}} maili dziennie",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} wiadomość dziennie",
"account_upgrade_dialog_tier_features_messages_few": "{{messages}} wiadomości dziennie",
"account_upgrade_dialog_tier_features_messages_many": "{{messages}} wiadomości dziennie"
"account_upgrade_dialog_tier_features_messages_many": "{{messages}} wiadomości dziennie",
"account_upgrade_dialog_tier_features_no_calls": "Brak połączeń telefonicznych",
"account_upgrade_dialog_tier_features_calls_one": "Dzienne rozmowy telefoniczne: {{calls}}",
"account_upgrade_dialog_tier_current_label": "Bieżące",
"account_basics_phone_numbers_dialog_number_label": "Numer telefonu",
"account_basics_phone_numbers_dialog_check_verification_button": "Potwierdź kod",
"account_upgrade_dialog_proration_info": "<strong>Proporcja</strong>: Przy ulepszaniu pomiędzy płatnymi planami, różnica ceny będzie <strong>pobrana natychmiast</strong>. Przy obniżaniu planu do niższych planów, środki zostaną użyte do rozliczenia przyszłych okresów subskrypcji.",
"account_upgrade_dialog_tier_features_emails_other": "Dzienne wiadomości e-mail: {{emails}}",
"account_upgrade_dialog_tier_features_calls_other": "Dzienne rozmowy telefoniczne: {{calls}}",
"account_basics_phone_numbers_dialog_verify_button_sms": "Wyślij SMS",
"account_tokens_dialog_expires_never": "Token nigdy nie wygasa",
"account_tokens_dialog_expires_x_days": "Token wygasa za {{days}} dni",
"account_basics_phone_numbers_dialog_description": "Aby używać funkcji powiadomień telefonicznych, musisz dodać i zweryfikować no najmniej jeden numer telefonu. Weryfikacja może być dokonana przez SMS lub połączenie telefoniczne.",
"account_upgrade_dialog_tier_features_messages_other": "Dzienne wiadomości: {{messages}}",
"account_basics_phone_numbers_no_phone_numbers_yet": "Brak numerów telefonów",
"account_tokens_delete_dialog_title": "Usuń token dostępu",
"publish_dialog_chip_call_label": "Rozmowa telefoniczna",
"account_basics_phone_numbers_dialog_title": "Dodaj numer telefonu",
"account_upgrade_dialog_tier_features_reservations_other": "Zarezerwowane tematy: {{reservations}}",
"account_upgrade_dialog_reservations_warning_one": "Wybrany plan zezwala na mniejszą liczbę zarezerwowanych tematów niż obecny. Przed zmianą planu, <strong>usuń co najmniej jedną rezerwację</strong>. Rezerwacje możesz usunąć w <Link>Ustawieniach</Link>.",
"account_basics_phone_numbers_description": "Dla powiadomień telefonicznych",
"account_upgrade_dialog_cancel_warning": "To <strong>anuluje Twoją subskrypcję</strong> i obniży status Twojego konta {{date}}. Tego dnia rezerwacja tematów oraz wiadomości przechowywane na serwerze <strong>zostaną usunięte</strong>.",
"account_tokens_dialog_expires_x_hours": "Token wygasa za {{hours}} godzin(y)",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Brak zweryfikowanych numerów telefonów",
"publish_dialog_call_label": "Rozmowa telefoniczna",
"account_usage_calls_title": "Wykonane połączenia telefoniczne",
"account_basics_phone_numbers_copied_to_clipboard": "Numer telefonu skopiowany do schowka",
"account_basics_phone_numbers_dialog_number_placeholder": "np. +1222333444",
"account_basics_phone_numbers_dialog_code_placeholder": "np. 123456",
"account_basics_phone_numbers_dialog_channel_call": "Zadzwoń",
"account_basics_phone_numbers_title": "Numery telefonów",
"account_usage_calls_none": "Nie wykonano żadnych połączeń z tego konta",
"publish_dialog_call_reset": "Usuń rozmowę telefoniczną",
"account_basics_phone_numbers_dialog_code_label": "Kod weryfikacyjny",
"account_basics_phone_numbers_dialog_verify_button_call": "Zadzwoń do mnie",
"publish_dialog_call_item": "Zadzwoń pod numer {{number}}",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_upgrade_dialog_tier_selected_label": "Wybrane",
"account_upgrade_dialog_reservations_warning_other": "Wybrany plan zezwala na mniejszą liczbę zarezerwowanych tematów niż obecny. Przed zmianą planu, <strong>usuń co najmniej tyle rezerwacji: {{count}}</strong>. Rezerwacje możesz usunąć w <Link>Ustawieniach</Link>."
}

View File

@@ -219,5 +219,69 @@
"signup_already_have_account": "Já possui uma conta? Entrar!",
"nav_upgrade_banner_label": "Atualizar para ntfy Pro",
"account_basics_phone_numbers_dialog_description": "Para usar o recurso de notificação de chamada, é necessários adicionar e verificar pelo menos um número de telefone. A verificação pode ser feita por SMS ou chamada telefônica.",
"account_basics_phone_numbers_description": "Para notificações de chamada telefônica"
"account_basics_phone_numbers_description": "Para notificações de chamada telefônica",
"account_basics_tier_interval_monthly": "mensal",
"account_basics_tier_canceled_subscription": "Sua assinatura foi cancelada e será rebaixada para uma conta gratuita em {{date}}.",
"account_basics_password_dialog_current_password_incorrect": "Senha incorreta",
"account_basics_phone_numbers_dialog_number_label": "Número de telefone",
"account_basics_password_dialog_button_submit": "Alterar senha",
"reserve_dialog_checkbox_label": "Guardar tópico e configurar acesso",
"account_basics_username_title": "Nome de usuário",
"account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código",
"account_usage_attachment_storage_title": "Armazenamento de anexos",
"account_usage_messages_title": "Mensagens publicadas",
"account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
"account_basics_tier_change_button": "Mudar",
"account_basics_tier_admin_suffix_with_tier": "(com nível {{tier}})",
"account_basics_title": "Conta",
"account_basics_phone_numbers_no_phone_numbers_yet": "Ainda não há números de telefone",
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome",
"display_name_dialog_description": "Defina um nome alternativo para o tópico exibido na lista de inscrições. Isso pode ajudar a identificar mais facilmente tópicos com nomes complicados.",
"publish_dialog_chip_call_label": "Chamada telefônica",
"account_basics_phone_numbers_dialog_title": "Adicionar número de telefone",
"account_basics_username_admin_tooltip": "Você é Administrador",
"account_usage_reservations_none": "Nenhum tópico reservado para esta conta",
"account_usage_title": "Uso",
"account_basics_tier_upgrade_button": "Atualizar para Pro",
"subscribe_dialog_error_topic_already_reserved": "Tópico já reservado",
"account_basics_tier_admin_suffix_no_tier": "(sem nível)",
"account_basics_tier_payment_overdue": "O teu pagamento está atrasado. Por favor, atualize seu método de pagamento, ou sua conta será rebaixada em breve.",
"account_basics_tier_description": "Nível de poder da sua conta",
"account_basics_tier_free": "Grátis",
"account_basics_tier_admin": "Administrador",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Nenhum número de telefone verificado",
"account_basics_password_description": "Alterar a senha da sua conta",
"publish_dialog_call_label": "Chamada telefônica",
"account_usage_calls_title": "Chamadas de telefone feitas",
"account_basics_tier_basic": "Básico",
"alert_not_supported_context_description": "Notificações são suportadas apenas através de HTTPS. Esta é uma limitação da <mdnLink>API de Notificações</mdnLink>.",
"account_basics_phone_numbers_copied_to_clipboard": "Número de telefone copiado para a área de transferência",
"account_basics_tier_title": "Tipo de conta",
"account_basics_phone_numbers_dialog_number_placeholder": "ex. +1222333444",
"account_basics_phone_numbers_dialog_code_placeholder": "ex. 123456",
"account_basics_tier_manage_billing_button": "Gerenciar faturamento",
"account_basics_username_description": "Ei, é você ❤",
"account_basics_password_dialog_confirm_password_label": "Confirmar senha",
"account_basics_tier_interval_yearly": "anual",
"account_basics_phone_numbers_dialog_channel_call": "Ligar",
"account_basics_password_title": "Senha",
"account_basics_password_dialog_new_password_label": "Nova senha",
"display_name_dialog_placeholder": "Nome de exibição",
"account_usage_of_limit": "de {{limit}}",
"account_basics_password_dialog_title": "Alterar senha",
"account_usage_limits_reset_daily": "Os limites de uso são redefinidos diariamente à meia-noite (UTC)",
"account_usage_unlimited": "Ilimitado",
"account_basics_password_dialog_current_password_label": "Senha atual",
"account_usage_reservations_title": "Tópicos reservados",
"account_usage_calls_none": "Nenhum telefonema pode ser feito com esta conta",
"display_name_dialog_title": "Alterar o nome de exibição",
"nav_upgrade_banner_description": "Guarde tópicos, mais mensagens & emails e anexos grandes",
"publish_dialog_call_reset": "Remover chamada telefônica",
"account_basics_phone_numbers_dialog_code_label": "Código de verificação",
"account_basics_tier_paid_until": "Assinatura paga até {{date}}, será renovada automaticamente",
"account_usage_attachment_storage_description": "{{filesize}} por arquivo, excluído após {{expiry}}",
"account_basics_phone_numbers_dialog_verify_button_call": "Ligar pra mim",
"publish_dialog_call_item": "Ligue para o número de telefone {{number}}",
"account_usage_emails_title": "Emails enviados",
"account_basics_phone_numbers_dialog_channel_sms": "SMS"
}

View File

@@ -101,5 +101,27 @@
"notifications_click_open_button": "Deschide link",
"publish_dialog_emoji_picker_show": "Alege un emoji",
"notifications_loading": "Încărcare notificări…",
"publish_dialog_priority_low": "Prioritate joasă"
"publish_dialog_priority_low": "Prioritate joasă",
"signup_form_username": "Nume de utilizator",
"signup_form_button_submit": "Înscrie-te",
"common_copy_to_clipboard": "Copiază în clipboard",
"signup_form_toggle_password_visibility": "Schimbă vizibilitatea parolei",
"signup_title": "Crează un cont ntfy",
"signup_already_have_account": "Deja ai un cont? Autentifică-te!",
"login_disabled": "Autentificarea este dezactivată",
"signup_error_creation_limit_reached": "S-a atins limita de conturi",
"action_bar_toggle_action_menu": "Deschide/Închide meniul de acțiuni",
"action_bar_sign_up": "Înscriere",
"message_bar_publish": "Publică mesajul",
"login_link_signup": "Înscrie-te",
"action_bar_sign_in": "Autentificare",
"action_bar_reservation_edit": "Schimbă rezervarea",
"action_bar_reservation_delete": "Șterge rezervarea",
"login_form_button_submit": "Autentifică-te",
"signup_disabled": "Înscrierea este dezactivată",
"action_bar_profile_logout": "Ieșire",
"message_bar_show_dialog": "Arată dialogul de publicare",
"signup_error_username_taken": "Numele de utilizator {{username}} este deja folosit",
"login_title": "Autentifică-te în contul ntfy",
"action_bar_reservation_add": "Rezervă topicul"
}

View File

@@ -155,7 +155,7 @@
"alert_grant_title": "Oznámenia sú vypnuté",
"alert_grant_button": "Prideliť teraz",
"alert_not_supported_title": "Oznámenia nie sú podporované",
"alert_not_supported_description": "Oznámenia nie sú vo vašom prehliadači podporované.",
"alert_not_supported_description": "Oznámenia nie sú vo vašom prehliadači podporované",
"notifications_attachment_copy_url_title": "Kopírovať URL adresu prílohy do schránky",
"notifications_attachment_copy_url_button": "Kopírovať adresu URL",
"notifications_attachment_open_title": "Prejsť na {{url}}",
@@ -380,5 +380,31 @@
"account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne <strong>vymažte aspoň {{count}} rezervácií</strong>. Rezervácie môžete odstrániť v <Link>Nastaveniach</Link>.",
"prefs_users_dialog_title_add": "Pridať používateľa",
"account_tokens_dialog_button_create": "Vytvoriť token",
"account_tokens_table_create_token_button": "Vytvoriť prístupový token"
"account_tokens_table_create_token_button": "Vytvoriť prístupový token",
"action_bar_mute_notifications": "Stlmiť oznámenia",
"action_bar_unmute_notifications": "Zrušiť stlmenie oznámení",
"alert_notification_permission_required_description": "Udeliť povolenie prehliadaču na zobrazovanie oznámení na ploche",
"alert_notification_permission_required_button": "Udeliť teraz",
"alert_notification_permission_denied_title": "Oznámenia sú zablokované",
"alert_notification_permission_denied_description": "Opätovne ich povoľte vo svojom prehliadači",
"alert_notification_ios_install_required_title": "Vyžaduje sa inštalácia iOS",
"notifications_actions_failed_notification": "Neúspešná akcia",
"publish_dialog_checkbox_markdown": "Formátovať ako Markdown",
"subscribe_dialog_subscribe_use_another_background_info": "Oznámenia z iných serverov sa nebudú prijímať, keď webová aplikácia nie je otvorená",
"prefs_notifications_web_push_title": "Oznámenia na pozadí",
"prefs_notifications_web_push_enabled_description": "Oznámenia sa prijímajú, aj keď webová aplikácia nie je spustená (prostredníctvom Web Push)",
"prefs_notifications_web_push_disabled_description": "Oznámenia sa prijímajú, keď je webová aplikácia spustená (cez WebSocket)",
"prefs_notifications_web_push_enabled": "Povolené pre {{server}}",
"prefs_notifications_web_push_disabled": "Zakázané",
"prefs_appearance_theme_title": "Téma",
"prefs_appearance_theme_system": "Systémové (predvolené)",
"prefs_appearance_theme_dark": "Tmavý režim",
"prefs_appearance_theme_light": "Svetlý režim",
"error_boundary_button_reload_ntfy": "Obnoviť ntfy",
"web_push_subscription_expiring_title": "Oznámenia budú pozastavené",
"web_push_subscription_expiring_body": "Ak chcete pokračovať v prijímaní upozornení, otvorte ntfy",
"web_push_unknown_notification_title": "Neznáme oznámenie prijaté zo servera",
"web_push_unknown_notification_body": "Možno budete musieť aktualizovať ntfy otvorením webovej aplikácie",
"alert_notification_permission_required_title": "Oznámenia sú vypnuté",
"alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS"
}

View File

@@ -1,10 +1,10 @@
{
"action_bar_settings": "Inställningar",
"action_bar_send_test_notification": "Skicka test notis",
"action_bar_send_test_notification": "Skicka testnotis",
"action_bar_toggle_action_menu": "Öppna/stäng åtgärdsmeny",
"message_bar_type_message": "Skriv ett meddelande här",
"message_bar_error_publishing": "Fel vid publicering av notis",
"message_bar_show_dialog": "Visa publicerings dialog",
"message_bar_show_dialog": "Visa publiceringsdialog",
"message_bar_publish": "Publicera meddelande",
"nav_topics_title": "Prenumererade kategorier",
"nav_button_all_notifications": "Alla notiser",
@@ -27,7 +27,7 @@
"notifications_attachment_link_expired": "Nedladdningslänk utgått",
"notifications_priority_x": "Prioritet {{priority}}",
"action_bar_show_menu": "Visa meny",
"action_bar_logo_alt": "ntfy logga",
"action_bar_logo_alt": "ntfy-logga",
"action_bar_unsubscribe": "Avprenumerera",
"action_bar_toggle_mute": "Tysta/aktivera notiser",
"action_bar_clear_notifications": "Rensa alla notiser",
@@ -36,12 +36,12 @@
"nav_button_settings": "Inställningar",
"nav_button_muted": "Notiser tystade",
"notifications_attachment_link_expires": "länken utgår {{date}}",
"notifications_attachment_file_image": "bild fil",
"notifications_attachment_file_audio": "ljud fil",
"notifications_attachment_file_image": "bildfil",
"notifications_attachment_file_audio": "ljudfil",
"alert_notification_permission_required_description": "Ge din webbläsare behörighet att visa skrivbordsnotiser.",
"alert_not_supported_description": "Notiser stöds inte i din webbläsare.",
"notifications_mark_read": "Markera som läst",
"notifications_attachment_file_video": "video fil",
"notifications_attachment_file_video": "videofil",
"notifications_click_copy_url_button": "Kopiera länk",
"notifications_click_open_button": "Öppna länk",
"notifications_actions_open_url_title": "Gå till {{url}}",
@@ -78,10 +78,10 @@
"signup_disabled": "Registrering är inaktiverad",
"signup_error_username_taken": "Användarnamn [[username]] används redan",
"notifications_attachment_file_document": "annat dokument",
"notifications_attachment_file_app": "Android app fil",
"notifications_attachment_file_app": "Android-appfil",
"notifications_click_copy_url_title": "Kopiera länk till urklipp",
"notifications_none_for_topic_title": "Du har inte fått några notiser för detta ämnet ännu.",
"notifications_none_for_topic_description": "För att kunna skicka notiser till detta ämnet, använd PUT eller POST till ämnets URL.",
"notifications_none_for_topic_description": "För att kunna skicka notiser till detta ämne, använd PUT eller POST till ämnets URL.",
"notifications_actions_http_request_title": "Skicka HTTP {{method}} till {{url}}",
"publish_dialog_progress_uploading": "Laddar upp …",
"nav_upgrade_banner_description": "Reservera ämnen, fler meddelanden och e-postmeddelanden och större bilagor",
@@ -103,7 +103,7 @@
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
"account_upgrade_dialog_button_cancel": "Avbryt",
"common_copy_to_clipboard": "Kopiera till urklipp",
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierad",
"account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
"account_tokens_table_create_token_button": "Skapa åtkomsttoken",
"prefs_users_description_no_sync": "Användare och lösenord synkroniseras inte till ditt konto.",
@@ -129,7 +129,7 @@
"account_upgrade_dialog_interval_yearly": "Årligen",
"account_upgrade_dialog_interval_yearly_discount_save": "spara {{discount}}%",
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "spara upp till {{discount}}%",
"account_upgrade_dialog_cancel_warning": "Detta kommer att <strong>säga upp din prenumeration</strong> och nedgradera ditt konto {{date}}. På det datumet kommer ämnesreservationer och meddelanden som ligger i cacheminnet på servern <strong>att raderas</strong>.",
"account_upgrade_dialog_cancel_warning": "Detta kommer att <strong>säga upp din prenumeration</strong> och nedgradera ditt konto {{date}}. På det datumet kommer ämnesreservationer och meddelanden som ligger i cacheminnet på servern <strong>att raderas</strong>.",
"account_upgrade_dialog_proration_info": "<strong>Deklaration</strong>: När du uppgraderar mellan betalda planer kommer prisskillnaden att <strong>debiteras omedelbart</strong>. Vid nedgradering till en lägre nivå kommer saldot att användas för att betala för framtida faktureringsperioder.",
"account_upgrade_dialog_reservations_warning_one": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, <strong>bör du ta bort minst en reservation</strong>. Du kan ta bort reservationer i <Link>Inställningar</Link>.",
"account_upgrade_dialog_reservations_warning_other": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, <strong>ta bort minst {{count}} reservationer</strong>. Du kan ta bort reservationer i <Link>Inställningar</Link>.",
@@ -363,7 +363,7 @@
"account_basics_phone_numbers_description": "För notifieringar via telefonsamtal",
"account_basics_phone_numbers_no_phone_numbers_yet": "Inga telefonnummer ännu",
"account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopierat till urklipp",
"account_basics_phone_numbers_dialog_title": "Lägga till telefonnummer",
"account_basics_phone_numbers_dialog_title": "Lägg till telefonnummer",
"account_basics_phone_numbers_dialog_number_label": "Telefonnummer",
"account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS",

View File

@@ -60,8 +60,8 @@
"nav_button_documentation": "Belgelendirme",
"nav_button_publish_message": "Bildirim yayınla",
"alert_notification_permission_required_title": "Bildirimler devre dışı",
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin.",
"alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor.",
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin",
"alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor",
"notifications_copied_to_clipboard": "Panoya kopyalandı",
"notifications_tags": "Etiketler",
"notifications_attachment_copy_url_title": "Ek URL'sini panoya kopyala",
@@ -380,5 +380,28 @@
"account_basics_phone_numbers_dialog_code_label": "Doğrulama kodu",
"account_basics_phone_numbers_dialog_code_placeholder": "örn. 123456",
"account_usage_calls_title": "Yapılan telefon aramaları",
"account_upgrade_dialog_tier_features_no_calls": "Telefon araması yok"
"account_upgrade_dialog_tier_features_no_calls": "Telefon araması yok",
"action_bar_mute_notifications": "Bildirimleri sessize al",
"action_bar_unmute_notifications": "Bildirimlerin sesini aç",
"alert_notification_permission_denied_title": "Bildirimler engellendi",
"alert_notification_permission_denied_description": "Lütfen tarayıcınızda yeniden etkinleştirin",
"alert_notification_ios_install_required_title": "iOS kurulumu gerekli",
"alert_notification_ios_install_required_description": "iOS'ta bildirimleri etkinleştirmek için Paylaş simgesine ve Ana Ekrana Ekle'ye tıklayın",
"notifications_actions_failed_notification": "Başarısız eylem",
"publish_dialog_checkbox_markdown": "Markdown olarak biçimlendir",
"prefs_notifications_web_push_title": "Arka plan bildirimleri",
"prefs_notifications_web_push_enabled_description": "Web uygulaması çalışmadığında bile bildirimler alınır (Web Push aracılığıyla)",
"prefs_notifications_web_push_disabled_description": "Web uygulaması çalışırken bildirim alınır (WebSocket aracılığıyla)",
"prefs_notifications_web_push_enabled": "{{server}} için etkinleştirildi",
"prefs_notifications_web_push_disabled": "Devre dışı",
"prefs_appearance_theme_title": "Tema",
"prefs_appearance_theme_system": "Sistem (öntanımlı)",
"prefs_appearance_theme_dark": "Koyu mod",
"prefs_appearance_theme_light": "Açık mod",
"error_boundary_button_reload_ntfy": "ntfy'yi yeniden yükle",
"web_push_subscription_expiring_title": "Bildirimler duraklatılacak",
"web_push_subscription_expiring_body": "Bildirimleri almaya devam etmek için ntfy'yi açın",
"web_push_unknown_notification_title": "Sunucudan bilinmeyen bildirim alındı",
"web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir",
"subscribe_dialog_subscribe_use_another_background_info": "Web uygulamasıık değilken diğer sunuculardan gelen bildirimler alınmayacaktır"
}

View File

@@ -0,0 +1,27 @@
{
"signup_title": "ntfy hisobini yaratish",
"signup_form_password": "Parol",
"signup_form_confirm_password": "Parolni tasdiqlang",
"signup_error_username_taken": "Foydalanuvchi nomi {{username}} allaqachon foydalanilmoqda",
"signup_error_creation_limit_reached": "Boshqa hisob raqam ocha olmaysiz",
"login_title": "Ntfy hisobingizga kiring",
"login_form_button_submit": "Kirish",
"login_link_signup": "Ro'yxatdan o'tish",
"login_disabled": "Kirish o'chirilgan",
"action_bar_show_menu": "Menyuni ko'rsatish",
"action_bar_logo_alt": "ntfy logotipi",
"action_bar_settings": "Sozlamalar",
"action_bar_change_display_name": "Ko'rsatilgan nomni o'zgartiring",
"action_bar_reservation_add": "Zaxira mavzusi",
"common_cancel": "Bekor qilish",
"common_save": "Saqlash",
"common_add": "Qoshish",
"common_back": "Orqaga",
"common_copy_to_clipboard": "Xotiraga nusxalash",
"signup_form_username": "Foydalanuvchi nomi",
"signup_form_button_submit": "Royxatdan otish",
"signup_form_toggle_password_visibility": "Parol korinishini ozgartirish",
"signup_already_have_account": "Hisobingiz bormi? Tizimga kiring!",
"signup_disabled": "Royxatdan otish ochirilgan",
"action_bar_account": "Hisob"
}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Box } from "@mui/material";
import { Box, Link } from "@mui/material";
import { useTranslation } from "react-i18next";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
@@ -32,16 +32,18 @@ const AttachmentIcon = (props) => {
imageLabel = t("notifications_attachment_file_document");
}
return (
<Box
component="img"
src={imageFile}
alt={imageLabel}
loading="lazy"
sx={{
width: "28px",
height: "28px",
}}
/>
<Link href={props.href} target="_blank">
<Box
component="img"
src={imageFile}
alt={imageLabel}
loading="lazy"
sx={{
width: "28px",
height: "28px",
}}
/>
</Link>
);
};

View File

@@ -10,6 +10,7 @@ import Navigation from "./Navigation";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [attachFile, setAttachFile] = useState(null);
const [dialogKey, setDialogKey] = useState(0);
const { dialogOpenMode } = props;
@@ -22,12 +23,30 @@ const Messaging = (props) => {
const handleDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey((prev) => prev + 1);
setAttachFile(null);
};
const getPastedImage = (ev) => {
const { items } = ev.clipboardData;
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.indexOf("image") !== -1) {
return items[i].getAsFile();
}
}
return null;
};
return (
<>
{subscription && (
<MessageBar subscription={subscription} message={message} onMessageChange={setMessage} onOpenDialogClick={handleOpenDialogClick} />
<MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onFilePasted={setAttachFile}
onOpenDialogClick={handleOpenDialogClick}
getPastedImage={getPastedImage}
/>
)}
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
@@ -35,6 +54,8 @@ const Messaging = (props) => {
baseUrl={subscription?.baseUrl ?? config.base_url}
topic={subscription?.topic ?? ""}
message={message}
attachFile={attachFile}
getPastedImage={getPastedImage}
onClose={handleDialogClose}
onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
@@ -56,6 +77,15 @@ const MessageBar = (props) => {
}
props.onMessageChange("");
};
const handlePaste = (ev) => {
const blob = props.getPastedImage(ev);
if (blob) {
props.onFilePasted(blob);
props.onOpenDialogClick();
}
};
return (
<Paper
elevation={3}
@@ -89,6 +119,7 @@ const MessageBar = (props) => {
handleSendClick();
}
}}
onPaste={handlePaste}
/>
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
<SendIcon />

View File

@@ -235,6 +235,19 @@ const PublishDialog = (props) => {
await checkAttachmentLimits(file);
};
useEffect(() => {
if (props.attachFile) {
updateAttachFile(props.attachFile);
}
}, [props.attachFile]);
const handlePaste = (ev) => {
const blob = props.getPastedImage(ev);
if (blob) {
updateAttachFile(blob);
}
};
const handleAttachFileChanged = async (ev) => {
await updateAttachFile(ev.target.files[0]);
};
@@ -357,6 +370,7 @@ const PublishDialog = (props) => {
inputProps={{
"aria-label": t("publish_dialog_message_label"),
}}
onPaste={handlePaste}
/>
<FormControlLabel
label={t("publish_dialog_checkbox_markdown")}
@@ -791,7 +805,7 @@ const AttachmentBox = (props) => {
borderRadius: "4px",
}}
>
<AttachmentIcon type={file.type} />
<AttachmentIcon type={file.type} href={URL.createObjectURL(file)} />
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
<ExpandingTextField
minWidth={140}