Compare commits

...

280 Commits

Author SHA1 Message Date
Philipp Heckel
f5981b851d Release notes and install instructions 2022-05-07 20:03:05 -04:00
Philipp Heckel
c357979f11 Upgrade mkdocs version; fix docs sidebar 2022-05-07 19:55:19 -04:00
Philipp Heckel
6ee3349cca Fix randomly failing test 2022-05-07 19:42:36 -04:00
Philipp Heckel
91e6eaab19 Add Hungarian 2022-05-07 19:26:17 -04:00
Philipp Heckel
3973f1e5ed Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-05-07 19:16:34 -04:00
Philipp Heckel
15ac5ed23b Add "mark as read" button 2022-05-07 19:16:08 -04:00
Hunter Kehoe
344da326cd add checkmark to notification card to mark notification as read 2022-05-07 16:13:45 -06:00
Dániel Agócs
cacfb704a4 Translated using Weblate (Hungarian)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/hu/
2022-05-07 20:13:17 +02:00
Philipp Heckel
040bb53383 Changelog 2022-05-07 13:47:34 -04:00
Philipp C. Heckel
5cac63bfbe Merge pull request #242 from SMAW/patch-2
Update publish.md
2022-05-07 13:44:25 -04:00
SMAW
8d908fe438 Update publish.md
Changed authentication Powershell documentation to create an Base64 UTF-8 string
2022-05-07 18:14:01 +02:00
Philipp Heckel
7db99d18c7 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-05-06 21:02:18 -04:00
Dániel Agócs
2bb5d6f934 Added translation using Weblate (Hungarian) 2022-05-06 18:39:05 +02:00
Philipp Heckel
bb13011046 Changelog 2022-05-06 09:15:16 -04:00
Philipp Heckel
8cc12e12da Changelog 2022-05-06 09:10:30 -04:00
Philipp Heckel
6e2b300d9e Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-05-06 09:10:24 -04:00
Ruben
1197d72523 Translated using Weblate (Dutch)
Currently translated at 3.2% (5 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nl/
2022-05-05 17:11:36 +02:00
Philipp Heckel
c1517e259d Fix rendering for publish example 2022-05-05 10:53:51 -04:00
Philipp Heckel
66d30fb42a Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-05-05 10:26:34 -04:00
Philipp C. Heckel
ea30132763 Merge pull request #234 from aTable/main
Full feature message example
2022-05-05 10:26:15 -04:00
aTable
1c237435ec Update publish.md 2022-05-05 21:17:28 +10:00
aTable
c37793d06f Update publish.md 2022-05-05 21:13:45 +10:00
aTable
57c7a353b5 Update publish.md 2022-05-05 21:10:14 +10:00
aTable
8154705f0b Update publish.md 2022-05-05 20:57:52 +10:00
aTable
fd2c6ef590 Update publish.md 2022-05-05 20:49:53 +10:00
aTable
6ece25e7f3 Add files via upload 2022-05-05 20:15:52 +10:00
aTable
a2ef6180bb Delete complete-notification.png 2022-05-05 20:14:29 +10:00
aTable
c3c4c9e9aa Update publish.md 2022-05-05 20:14:01 +10:00
Philipp Heckel
031c848984 Improved caddy configuration 2022-05-04 15:45:20 -04:00
Ruben
1f70ff1b06 Added translation using Weblate (Dutch) 2022-05-04 16:34:39 +02:00
aTable
1af9a85847 Update publish.md 2022-05-04 23:33:11 +10:00
aTable
e474d1e8b0 Delete multiline-notification.png 2022-05-04 23:29:00 +10:00
aTable
4b117a790a Add files via upload 2022-05-04 23:27:50 +10:00
aTable
72a2a8c82e Update publish.md 2022-05-04 23:24:38 +10:00
aTable
a7af16beb1 Update publish.md 2022-05-04 21:29:22 +10:00
aTable
7d38bc7654 Add files via upload 2022-05-04 21:24:20 +10:00
aTable
bcbcbf12ac Update publish.md 2022-05-04 21:23:17 +10:00
Philipp Heckel
8b5d2a8ca0 Changelog 2022-05-03 19:40:27 -04:00
Philipp Heckel
9b8e637618 Changelog 2022-05-03 18:01:50 -04:00
Philipp Heckel
15a45d9eb7 More labels, and live regions 2022-05-03 15:09:20 -04:00
Philipp Heckel
8a7bc38861 Finish up the labelling 2022-05-03 14:53:07 -04:00
Philipp Heckel
2d96560375 Finish publish dialog aria- stuff 2022-05-02 20:02:21 -04:00
Philipp Heckel
bb5e0e3fed WIP: Accessibility of web app 2022-05-02 19:30:29 -04:00
Philipp Heckel
4a8678bf39 Changelog 2022-05-01 20:26:41 -04:00
Philipp Heckel
ed28082c01 Added French 2022-04-30 20:16:17 -04:00
Philipp Heckel
0d3dcfdc7a Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-30 20:12:47 -04:00
Nathanaël Houn
672203467d Translated using Weblate (French)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2022-05-01 02:12:40 +02:00
Philipp Heckel
4ce619f9cb Add error message specifically for private browsing mode, closes #208 2022-04-29 20:51:26 -04:00
Philipp Heckel
5344337b43 Add Czech as language 2022-04-29 20:12:12 -04:00
Philipp Heckel
cf3238859c Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-29 20:03:00 -04:00
Philipp Heckel
9a03a9e81b Made web app sounds quieter 2022-04-29 19:51:02 -04:00
Philipp Heckel
edfed24c27 Make Upgrade header check for websockets case insensitive, closes #228 2022-04-29 13:23:04 -04:00
waclaw66
7118dcc124 Translated using Weblate (Czech)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/
2022-04-28 15:13:43 +02:00
Linerly
5bcb35f756 Translated using Weblate (Indonesian)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2022-04-28 15:13:41 +02:00
Nathanaël Houn
eaf3c42227 Translated using Weblate (French)
Currently translated at 49.3% (76 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2022-04-28 15:13:41 +02:00
Rogelio Dominguez
16a4feaeb6 Translated using Weblate (Spanish)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2022-04-28 15:13:40 +02:00
109247019824
b60458318c Translated using Weblate (Bulgarian)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-28 15:13:40 +02:00
Oğuz Ersen
b10c88afd7 Translated using Weblate (Turkish)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2022-04-28 15:13:40 +02:00
Christian Meis
f0cae0fbac Translated using Weblate (German)
Currently translated at 100.0% (154 of 154 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-28 15:13:39 +02:00
Philipp Heckel
28bb8d4446 More actions tests 2022-04-27 14:28:58 -04:00
Philipp Heckel
adea3c38be Remove backslash from quoted strings 2022-04-27 13:56:21 -04:00
Philipp C. Heckel
fb56ab9a06 Merge pull request #225 from binwiederhier/actions-parsing
WIP: More advanced action parsing
2022-04-27 11:24:40 -04:00
Philipp Heckel
72aea2613a Remove superflous if statement 2022-04-27 11:23:44 -04:00
Philipp Heckel
6bd4e4bd7c User actions docs, tests and release notes 2022-04-27 10:25:01 -04:00
Philipp Heckel
1f6118f068 Finish up better parsing 2022-04-27 09:51:23 -04:00
Philipp Heckel
574e72a974 WIP: More advanced action parsing 2022-04-26 23:07:31 -04:00
Philipp Heckel
53646737e8 Changelog 2022-04-25 10:22:23 -04:00
waclaw66
26b9cc75ca Added translation using Weblate (Czech) 2022-04-25 15:07:10 +02:00
Philipp Heckel
66e46aaded Add Docker build for ARMv6, bump again 2022-04-24 22:25:34 -04:00
Philipp Heckel
ddf5d49895 Update install instructions, bump version 2022-04-24 22:09:58 -04:00
Philipp Heckel
899d895f29 Add ARMv6 install instructions 2022-04-24 21:53:08 -04:00
Philipp Heckel
27588b8a48 Makefile for ARMv6 2022-04-24 21:41:40 -04:00
Philipp Heckel
c3f4adb777 Revert urfave/cli, see https://github.com/urfave/cli/issues/1373 2022-04-24 21:20:49 -04:00
Philipp Heckel
3633503549 Bump version, update deps 2022-04-24 20:32:17 -04:00
Philipp Heckel
5494bcce88 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-24 20:19:03 -04:00
Tiago Esperança Triques
b824a1f17f Translated using Weblate (Portuguese (Brazil))
Currently translated at 23.6% (36 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2022-04-25 02:18:20 +02:00
Philipp Heckel
5a6b6dacc0 Changelog 2022-04-24 20:01:14 -04:00
Tiago Esperança Triques
0c6e9d4fca Added translation using Weblate (Portuguese (Brazil)) 2022-04-24 23:44:03 +02:00
Philipp Heckel
0d0ca188bf Fix changelog 2022-04-23 20:48:44 -04:00
Philipp Heckel
9c91ae2744 Make sure clear= values are checked 2022-04-23 15:23:18 -04:00
Philipp Heckel
882f027f6c Changelog 2022-04-23 13:45:26 -04:00
Philipp Heckel
b805d49cfd Disallow HEAD/GET requests with body 2022-04-23 13:40:26 -04:00
Philipp Heckel
12f85cceb1 Add clear=true|false support to actions 2022-04-22 23:07:35 -04:00
Philipp Heckel
8f4a1db1f0 Changelog, add tests 2022-04-22 14:51:44 -04:00
Philipp Heckel
58bde32bfb Fix docs 2022-04-22 13:53:32 -04:00
Philipp Heckel
26b3aa27ae Add proper screenshot 2022-04-21 20:35:25 -04:00
Philipp Heckel
26ebd23bfd Add user actions to web app 2022-04-21 16:33:49 -04:00
Philipp Heckel
12d347976c Docs docs docs 2022-04-21 13:57:42 -04:00
Philipp Heckel
a779434bab More docs 2022-04-21 09:58:28 -04:00
Philipp Heckel
c5ec3b48b4 Ahhh 2022-04-20 19:15:15 -04:00
Philipp Heckel
712c292183 More docs 2022-04-20 16:31:25 -04:00
Philipp Heckel
8900df27c9 Docs, still WIP 2022-04-19 23:26:46 -04:00
Philipp Heckel
0eb511c714 Merge branch 'main' into actions 2022-04-19 19:37:35 -04:00
Philipp Heckel
d48eec5e66 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-19 19:37:19 -04:00
Philipp Heckel
3a7fd7a620 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into actions 2022-04-19 19:37:13 -04:00
Philipp Heckel
85043b34a4 Merge branch 'main' into actions 2022-04-19 19:33:25 -04:00
Philipp Heckel
2df0e98749 Added Russian to changelog + web app 2022-04-19 19:31:50 -04:00
Philipp Heckel
3c3b2477af Docs (WIP), Firebase 2022-04-19 19:22:19 -04:00
Philipp Heckel
5a9b2122c2 Make simple actions parsing work 2022-04-19 09:14:32 -04:00
Ilya Mikheev
37e72e078d Translated using Weblate (Russian)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2022-04-19 09:12:56 +02:00
Aleksej Muratov
a2dafc11f2 Translated using Weblate (Russian)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2022-04-19 09:12:56 +02:00
Philipp Heckel
55869f551e Add ID 2022-04-17 14:29:43 -04:00
Philipp Heckel
967cde1fb5 JSON format 2022-04-16 20:12:01 -04:00
Philipp Heckel
26efd481e3 WIP Actions 2022-04-16 16:17:58 -04:00
Philipp Heckel
06fd7327de Docs for DND override 2022-04-15 15:39:58 -04:00
Philipp Heckel
a08d57ca0f Changelog 2022-04-15 12:19:48 -04:00
Philipp Heckel
c60c51871d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-15 12:16:24 -04:00
Erik S
04c4150283 Translated using Weblate (Russian)
Currently translated at 42.7% (65 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2022-04-15 18:16:18 +02:00
109247019824
1feb038385 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-15 18:16:17 +02:00
Erik S
61a5b0dbe9 Added translation using Weblate (Russian) 2022-04-14 09:02:20 +02:00
Philipp Heckel
690cd683f0 Changelog 2022-04-13 21:38:56 -04:00
Philipp Heckel
c87c81f663 Add WebSockets support to docs/config 2022-04-13 14:06:20 -04:00
Philipp Heckel
8190d5b1f4 Added docs for "Share to topic" and "ntfy:// links" 2022-04-12 20:10:05 -04:00
Philipp Heckel
75c11371e6 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-11 22:00:14 -04:00
Rogelio Dominguez
ffa0bf05cd Translated using Weblate (Spanish)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2022-04-12 04:00:09 +02:00
Philipp Heckel
8e1c57af25 Added Norwegian 2022-04-11 20:18:18 -04:00
Shoshin Akamine
c62916a43c Translated using Weblate (Japanese)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2022-04-12 01:56:34 +02:00
Linerly
f5145ffaae Translated using Weblate (Indonesian)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2022-04-12 01:56:34 +02:00
109247019824
0a6aba1ac7 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-12 01:56:33 +02:00
Allan Nordhøy
5d30246c35 Translated using Weblate (Norwegian Bokmål)
Currently translated at 76.3% (116 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nb_NO/
2022-04-12 01:56:33 +02:00
Oğuz Ersen
e9386ecfe3 Translated using Weblate (Turkish)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2022-04-12 01:56:31 +02:00
Christian Meis
04f5d4acb7 Translated using Weblate (German)
Currently translated at 100.0% (152 of 152 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-12 01:56:31 +02:00
Philipp Heckel
841c08fcb6 Adding Spanish translation 2022-04-10 15:21:13 -04:00
Philipp Heckel
2d7c354723 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-10 15:16:11 -04:00
Rogelio Dominguez
6fec79055e Translated using Weblate (Spanish)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2022-04-10 21:16:06 +02:00
109247019824
f61a8f82a7 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-10 21:16:05 +02:00
Philipp Heckel
136883fd94 Additional descriptions for settings (#203), URL validation (#204) 2022-04-10 15:13:12 -04:00
Philipp Heckel
9c3f5929c7 Changelog 2022-04-09 15:12:03 -04:00
Philipp Heckel
39bd1fe164 Added Japanese + Indonesian to web app 2022-04-09 10:54:09 -04:00
Philipp Heckel
67ea467501 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-09 10:47:47 -04:00
Shoshin Akamine
ed946195e2 Translated using Weblate (Japanese)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2022-04-09 16:47:41 +02:00
Linerly
84bf95fa85 Translated using Weblate (Indonesian)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2022-04-09 16:47:39 +02:00
109247019824
cf9ba9b1f9 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-09 16:47:38 +02:00
Oğuz Ersen
1a18ce9e21 Translated using Weblate (Turkish)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2022-04-09 16:47:37 +02:00
Philipp Heckel
044b717f86 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-09 10:46:00 -04:00
Shoshin Akamine
8777718afc Added translation using Weblate (Japanese) 2022-04-09 09:12:08 +02:00
Linerly
8e3910c76d Added translation using Weblate (Indonesian) 2022-04-09 04:39:31 +02:00
Philipp Heckel
448444eccf Show snack bar error message when publishing fails, closes #205 2022-04-08 20:24:11 -04:00
Philipp Heckel
65cd380527 Service URL 2022-04-08 19:31:50 -04:00
J. Lavoie
71a49ac1a6 Translated using Weblate (French)
Currently translated at 39.4% (54 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2022-04-09 01:30:55 +02:00
Rogelio Dominguez
1fba62276c Translated using Weblate (Spanish)
Currently translated at 13.1% (18 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2022-04-09 01:30:55 +02:00
Christian Meis
29f265be30 Translated using Weblate (German)
Currently translated at 98.5% (135 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-09 00:10:19 +02:00
Christian Meis
4c9011f391 Translated using Weblate (German)
Currently translated at 98.5% (135 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-09 00:01:15 +02:00
Christian Meis
155475422e Translated using Weblate (German)
Currently translated at 98.5% (135 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-08 23:53:10 +02:00
Christian Meis
32353e0f02 Translated using Weblate (German)
Currently translated at 98.5% (135 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-08 23:51:11 +02:00
Christian Meis
69159b9aae Translated using Weblate (German)
Currently translated at 98.5% (135 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-08 23:43:22 +02:00
J. Lavoie
b47d0ac240 Translated using Weblate (German)
Currently translated at 98.5% (135 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-08 23:43:22 +02:00
J. Lavoie
d14af78403 Translated using Weblate (French)
Currently translated at 16.0% (22 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/fr/
2022-04-08 23:29:05 +02:00
Rogelio Dominguez
9cb08036ef Translated using Weblate (Spanish)
Currently translated at 10.9% (15 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2022-04-08 23:29:04 +02:00
109247019824
e0da6b1302 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-08 23:29:04 +02:00
Christian Meis
fcb1f938b9 Translated using Weblate (German)
Currently translated at 98.5% (135 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-08 23:29:04 +02:00
J. Lavoie
9c094c1cc3 Added translation using Weblate (French) 2022-04-08 23:25:41 +02:00
Rogelio Dominguez
69c6f24d97 Added translation using Weblate (Spanish) 2022-04-08 21:57:48 +02:00
Philipp Heckel
e8b020ff45 Replace placeholders 2022-04-08 15:26:14 -04:00
Philipp Heckel
2ec9a7307e Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-08 15:21:54 -04:00
Philipp Heckel
738ee5cf35 Suggested fixes for delay string, widen priority dropdown, add German and Turkish 2022-04-08 15:21:22 -04:00
109247019824
8144d39e29 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-08 21:21:05 +02:00
Oğuz Ersen
788d5e9f9b Translated using Weblate (Turkish)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2022-04-08 21:21:05 +02:00
Philipp Heckel
d399d2fe1c Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-08 14:56:15 -04:00
109247019824
615b09a774 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-08 20:56:03 +02:00
Oğuz Ersen
7a5e8cc44b Translated using Weblate (Turkish)
Currently translated at 7.2% (10 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2022-04-08 20:56:03 +02:00
Christian Meis
291b49488b Translated using Weblate (German)
Currently translated at 97.8% (134 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-04-08 20:56:02 +02:00
Philipp Heckel
aa58242551 Update language array to match finished languages 2022-04-08 12:54:53 -04:00
Philipp Heckel
b08ea2c416 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web into main 2022-04-08 12:47:28 -04:00
109247019824
98f02f78db Translated using Weblate (Bulgarian)
Currently translated at 100.0% (137 of 137 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/bg/
2022-04-08 18:47:19 +02:00
Philipp Heckel
d2f933e15f Fix English language strings, as per #203 2022-04-08 12:45:41 -04:00
109247019824
d672969840 Added translation using Weblate (Bulgarian) 2022-04-08 17:00:21 +02:00
Allan Nordhøy
8c4f0c1253 Translated using Weblate (Norwegian Bokmål)
Currently translated at 100.0% (1 of 1 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/nb_NO/
2022-04-08 16:48:46 +02:00
Allan Nordhøy
18c88e567c Added translation using Weblate (Norwegian Bokmål) 2022-04-08 16:48:46 +02:00
Oğuz Ersen
2c5505852e Translated using Weblate (Turkish)
Currently translated at 100.0% (1 of 1 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2022-04-08 16:48:46 +02:00
Oğuz Ersen
bc8f245064 Added translation using Weblate (Turkish) 2022-04-08 16:48:46 +02:00
Philipp Heckel
30726144b8 Finish web app translation 2022-04-08 10:44:35 -04:00
Philipp Heckel
893701c07b Extracting translation strings 2022-04-07 21:46:33 -04:00
Philipp Heckel
0ec9a4c89b Changelog 2022-04-07 20:33:15 -04:00
Philipp Heckel
96fb7e2296 Working language switcher 2022-04-07 20:31:24 -04:00
Philipp Heckel
750e390b5d WIP: Translation of web app 2022-04-07 19:11:51 -04:00
Philipp Heckel
7a8cfb5f66 Changelog 2022-04-07 15:41:44 -04:00
Philipp Heckel
d761ce929c Merge branch 'armv6' into main 2022-04-07 13:35:13 -04:00
Philipp Heckel
29969582e9 Update go.sum 2022-04-06 20:50:36 -04:00
Philipp Heckel
749e334396 Bump version 2022-04-06 20:33:19 -04:00
Philipp Heckel
78a681f277 Fix UTF-8 issues in publish message dialog 2022-04-06 20:04:27 -04:00
Philipp Heckel
e22ec2c505 ARMv6 2022-04-06 19:45:30 -04:00
Philipp Heckel
3f96fad7ce Remove now unused splitTopicUrl function 2022-04-06 13:27:32 -04:00
Philipp Heckel
83bb9951b0 Split baseUrl and topic 2022-04-05 23:33:07 -04:00
Philipp Heckel
4a5f34801a Do not hide notification behind message bar 2022-04-05 22:57:57 -04:00
Philipp Heckel
2cd7839da3 Docblock 2022-04-05 19:55:43 -04:00
Philipp Heckel
35ddcb27f0 Good enough emoji picker 2022-04-05 19:40:34 -04:00
Philipp Heckel
328aca48ab Filter emojis that don't render in Chrome on Desktop 2022-04-04 20:44:40 -04:00
Philipp Heckel
4eba641ec3 Emoji picker 2022-04-04 19:56:21 -04:00
Philipp Heckel
f2d4af04e3 Emoji picker 2022-04-04 10:04:01 -04:00
Philipp Heckel
d44ee2bbf6 Rename Icon->AttachmentIcon 2022-04-04 08:40:54 -04:00
Philipp Heckel
6f07944442 Publish message button 2022-04-03 22:58:44 -04:00
Philipp Heckel
7716b1e81e Push drop zone down to dialog 2022-04-03 22:42:56 -04:00
Philipp Heckel
8914809775 Remove showOpen 2022-04-03 22:28:41 -04:00
Philipp Heckel
dcb5531038 Merge branch 'main' into custom-messages 2022-04-03 22:17:45 -04:00
Philipp Heckel
0c666f96b1 Added Apprise to release notes 2022-04-03 22:17:29 -04:00
Philipp Heckel
d9c3c20350 Publish message button 2022-04-03 22:11:26 -04:00
Philipp Heckel
73349cd423 Add test 2022-04-03 20:19:43 -04:00
Philipp Heckel
b3667a916b Merge branch 'main' into custom-messages 2022-04-03 19:51:46 -04:00
Philipp Heckel
6791c7395b Almost there 2022-04-03 19:51:32 -04:00
Philipp Heckel
aba7e86cbc Attachment behavior fix for Firefox 2022-04-03 12:39:52 -04:00
Philipp Heckel
12f973f61b Changelog 2022-04-02 17:16:46 -04:00
Philipp Heckel
f98743dd9b Continued work on send dialog and drag and drop 2022-04-02 17:06:26 -04:00
Philipp Heckel
2c8b258ae7 Publish another checkbox 2022-04-01 11:34:53 -04:00
Philipp Heckel
00520a7a38 Merge branch 'main' into custom-messages 2022-04-01 08:54:29 -04:00
Philipp Heckel
611894fd05 Release notes, add EXPOSE 80 to Dockerfile 2022-04-01 08:49:15 -04:00
Philipp Heckel
aabae53e5d File upload 2022-04-01 08:41:45 -04:00
Philipp C. Heckel
85cf7bb687 Merge pull request #194 from RasHas/patch-1
Update install docs
2022-03-31 18:17:07 -04:00
Philipp C. Heckel
44b9358c60 Update install.md 2022-03-31 18:16:56 -04:00
RasHas
ee6188d100 Update install docs
Add docker-compose example
2022-03-31 23:11:39 +03:00
Philipp Heckel
2bdae49425 Make Attach URL prettier 2022-03-31 12:03:36 -04:00
Philipp Heckel
9814a9f792 Merge branch 'main' into custom-messages 2022-03-31 10:06:55 -04:00
Philipp Heckel
5aedfd3898 Changelog 2022-03-30 20:17:11 -04:00
Philipp Heckel
59ec2de8bd Fix race in test 2022-03-30 14:37:42 -04:00
Philipp Heckel
d154d3936d Bump version, release notes 2022-03-30 14:26:31 -04:00
Philipp Heckel
5125aac91c Remove upx for arm64/armv7, more translation credits 2022-03-30 14:23:57 -04:00
Philipp Heckel
7ff34364a3 Editable attachment filename 2022-03-30 14:11:18 -04:00
Philipp Heckel
3d0d70dc17 Merge branch 'main' into custom-messages 2022-03-30 10:01:40 -04:00
Philipp Heckel
62512b7a1a Change deprecation warning 2022-03-30 10:01:16 -04:00
Philipp Heckel
c5a1344e8a WIP: Make attachment filename editabe 2022-03-30 09:57:22 -04:00
Philipp Heckel
402b05a27b Merge branch 'main' into custom-messages 2022-03-29 19:37:06 -04:00
Philipp Heckel
b67d9fc85d Added missing 'delay' and 'email' params to publish as json 2022-03-29 15:40:26 -04:00
Philipp Heckel
3e121f5d3c Continued work on the send dialog 2022-03-29 15:22:26 -04:00
Philipp Heckel
b6426f0417 Merge branch 'main' into custom-messages 2022-03-29 11:54:50 -04:00
Philipp Heckel
59b341dfb8 Fix color of home page 2022-03-29 11:47:56 -04:00
Philipp Heckel
e2834a7c4d Changelog 2022-03-29 10:44:12 -04:00
Philipp Heckel
e0b3068a5e Merge branch 'main' into custom-messages 2022-03-28 23:10:54 -04:00
Philipp Heckel
2280031a80 Release notes, translations 2022-03-28 23:10:44 -04:00
Philipp Heckel
8f2851e20a Release notes, translations 2022-03-28 23:10:05 -04:00
Philipp Heckel
2eeb7d63a0 SendDialog, cont'd 2022-03-28 22:54:27 -04:00
Philipp Heckel
b20df55b88 Merge branch 'main' into custom-messages 2022-03-28 14:14:20 -04:00
Philipp Heckel
de1b97bbce Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-03-28 14:10:24 -04:00
Philipp Heckel
3b4a4108e5 Release log 2022-03-28 14:10:14 -04:00
Philipp C. Heckel
dc1c0ddd4e Update README.md 2022-03-28 11:07:05 -04:00
Philipp Heckel
182e21a9c3 Fix pruning bug in web app (closes #186), release notes, remove local storage migration 2022-03-27 09:20:25 -04:00
Philipp Heckel
187c19f3b2 Continued work on publishing from the web app 2022-03-27 09:10:47 -04:00
Philipp Heckel
d5eff0cd34 Merge branch 'main' into custom-messages 2022-03-26 14:17:28 -04:00
Philipp Heckel
d4fe2052c7 Release notes 2022-03-26 13:33:16 -04:00
Philipp Heckel
2e92be0f23 Remove other fields 2022-03-26 09:32:13 -04:00
Philipp Heckel
94b0e6f690 Merge branch 'main' into custom-messages 2022-03-25 21:43:45 -04:00
Philipp Heckel
202051bbbf Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-03-25 21:35:56 -04:00
Philipp Heckel
a693975526 Email docs and release notes 2022-03-25 21:35:40 -04:00
Philipp C. Heckel
4cd4e890fe Update README.md 2022-03-25 18:56:22 -04:00
Philipp C. Heckel
5dc8031ec9 Update README.md 2022-03-25 18:54:40 -04:00
Philipp Heckel
03ad5dcff6 Add Allow-Origin: *, because YOLO 2022-03-25 17:17:24 -04:00
Philipp Heckel
5f508e1839 Tiny docs changes 2022-03-25 13:51:04 -04:00
Philipp C. Heckel
c5642799df Merge pull request #178 from nickexyz/docs-noderedupdate
Add Node-RED pictures and change ntfy URL to ntfy.sh
2022-03-25 13:32:05 -04:00
Philipp Heckel
5a99fe8ba2 Try it a better way 2022-03-24 19:27:07 -04:00
Philipp Heckel
ee0f448d86 Another try 2022-03-24 19:25:41 -04:00
Philipp Heckel
a222f64ee4 Hopefully fix the build 2022-03-24 19:23:30 -04:00
Philipp Heckel
140daec0d3 Fix date formatting issue in example 2022-03-24 13:17:04 -04:00
Philipp Heckel
b409c89d3b Do not allow comma in topic name in publish via GET endpoint (no ticket) 2022-03-23 14:29:55 -04:00
Philipp Heckel
806893962c Changelog 2022-03-23 11:02:12 -04:00
Philipp Heckel
14d3c5e93e Changelog 2022-03-22 20:31:43 -04:00
Philipp Heckel
37e14b13a4 Update deps 2022-03-22 19:54:20 -04:00
Philipp Heckel
d7fa51be2c Changelog 2022-03-22 15:26:15 -04:00
Philipp Heckel
a3e28e71aa Bump version 2022-03-21 23:23:46 -04:00
Philipp Heckel
35cef8386c Release docs 2022-03-21 23:13:45 -04:00
Philipp Heckel
38072c9cdd Release log 2022-03-20 19:55:32 -04:00
Philipp Heckel
13d741b89e Changelog 2022-03-20 15:12:06 -04:00
Philipp Heckel
cc90a1af15 WIP: custom messages 2022-03-20 13:52:07 -04:00
nickexyz
21fc1245eb Update examples.md 2022-03-19 00:20:56 +00:00
Niclas Andersson
2511ba7627 Add Node-RED pictures and change ntfy URL to ntfy.sh 2022-03-19 01:16:02 +01:00
Philipp Heckel
23547f4504 New lines 2022-03-18 19:39:37 -04:00
Philipp Heckel
e6f19d050f Updated examples, release notes 2022-03-18 17:34:14 -04:00
Philipp C. Heckel
3ec8084450 Merge pull request #176 from nickexyz/docs-update
Add Node-RED and Gatus examples.
2022-03-18 17:27:57 -04:00
Philipp Heckel
2edb722c0e Refinement 2022-03-18 17:18:52 -04:00
Philipp Heckel
1f75498dca More docs 2022-03-18 17:00:08 -04:00
Niclas Andersson
ab19c4d688 Add Node-RED and Gatus examples. 2022-03-18 21:58:33 +01:00
Philipp Heckel
15265d9ef3 Merge branch 'main' into develop-docs 2022-03-18 15:08:29 -04:00
Philipp Heckel
2839a7228f Merge branch 'main' of github.com:binwiederhier/ntfy into HEAD 2022-03-18 15:00:01 -04:00
Philipp Heckel
c2036975fa Lots of development instructions, Makefile things 2022-03-18 13:53:52 -04:00
Joe Harrison
7aa0f87376 Merge pull request #1 from Joeharrison94/Joeharrison94-patch-1
Update to publish.md to add PowerShell examples
2022-03-18 09:18:01 +00:00
Joe Harrison
df372d1a7e Update to publish.md to add PowerShell examples 2022-03-18 09:17:19 +00:00
Philipp Heckel
6cd31502e7 Merge branch 'main' of github.com:binwiederhier/ntfy into develop-docs 2022-03-17 16:04:15 -04:00
Philipp C. Heckel
bade88079f Merge pull request #173 from nickexyz/arr_docs
Add examples for *arr notification scripts
2022-03-17 16:03:42 -04:00
Niclas Andersson
20ab05afc8 Add examples for *arr notification scripts 2022-03-17 19:20:48 +01:00
Philipp Heckel
5b10f51af1 WIP: Develop docs 2022-03-16 22:33:23 -04:00
Philipp Heckel
470d11f442 Fix install instructions 2022-03-16 21:40:56 -04:00
Philipp Heckel
4952f0fbd2 Tiny fixes to release notes 2022-03-16 21:05:51 -04:00
90 changed files with 10082 additions and 3319 deletions

View File

@@ -4,7 +4,7 @@ before:
- go mod tidy
builds:
-
id: ntfy
id: ntfy_amd64
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
@@ -16,6 +16,20 @@ builds:
hooks:
post:
- upx "{{ .Path }}" # apt install upx
-
id: ntfy_armv6
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
tags: [sqlite_omit_load_extension,osusergo,netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm]
goarm: [6]
# No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
-
id: ntfy_armv7
binary: ntfy
@@ -28,9 +42,8 @@ builds:
goos: [linux]
goarch: [arm]
goarm: [7]
hooks:
post:
- upx "{{ .Path }}" # apt install upx
# No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
-
id: ntfy_arm64
binary: ntfy
@@ -42,9 +55,8 @@ builds:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm64]
hooks:
post:
- upx "{{ .Path }}" # apt install upx
# No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
nfpms:
-
package_name: ntfy
@@ -126,14 +138,24 @@ dockers:
goarm: 7
build_flag_templates:
- "--platform=linux/arm/v7"
- image_templates:
- &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6"
use: buildx
dockerfile: Dockerfile
goarch: arm
goarm: 6
build_flag_templates:
- "--platform=linux/arm/v6"
docker_manifests:
- name_template: "binwiederhier/ntfy:latest"
image_templates:
- *amd64_image
- *arm64v8_image
- *armv7_image
- *armv6_image
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
image_templates:
- *amd64_image
- *arm64v8_image
- *armv7_image
- *armv6_image

View File

@@ -2,4 +2,6 @@ FROM alpine
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
COPY ntfy /usr/bin
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

205
Makefile
View File

@@ -3,55 +3,90 @@ VERSION := $(shell git describe --tag)
.PHONY:
help:
@echo "Typical commands:"
@echo " make check - Run all tests, vetting/formatting checks and linters"
@echo " make fmt build-snapshot install - Build latest and install to local system"
@echo "Typical commands (more see below):"
@echo " make build - Build web app, documentation and server/client (sloowwww)"
@echo " make server-amd64 - Build server/client binary (amd64, no web app or docs)"
@echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
@echo " make web - Build the web app"
@echo " make docs - Build the documentation"
@echo " make check - Run all tests, vetting/formatting checks and linters"
@echo
@echo "Build everything:"
@echo " make build - Build web app, documentation and server/client"
@echo " make clean - Clean build/dist folders"
@echo
@echo "Build server & client (not release version):"
@echo " make server - Build server & client (all architectures)"
@echo " make server-amd64 - Build server & client (amd64 only)"
@echo " make server-armv6 - Build server & client (armv6 only)"
@echo " make server-armv7 - Build server & client (armv7 only)"
@echo " make server-arm64 - Build server & client (arm64 only)"
@echo
@echo "Build web app:"
@echo " make web - Build the web app"
@echo " make web-deps - Install web app dependencies (npm install the universe)"
@echo " make web-build - Actually build the web app"
@echo
@echo "Build documentation:"
@echo " make docs - Build the documentation"
@echo " make docs-deps - Install Python dependencies (pip3 install)"
@echo " make docs-build - Actually build the documentation"
@echo
@echo "Test/check:"
@echo " make test - Run tests"
@echo " make race - Run tests with -race flag"
@echo " make coverage - Run tests and show coverage"
@echo " make coverage-html - Run tests and show coverage (as HTML)"
@echo " make coverage-upload - Upload coverage results to codecov.io"
@echo " make test - Run tests"
@echo " make race - Run tests with -race flag"
@echo " make coverage - Run tests and show coverage"
@echo " make coverage-html - Run tests and show coverage (as HTML)"
@echo " make coverage-upload - Upload coverage results to codecov.io"
@echo
@echo "Lint/format:"
@echo " make fmt - Run 'go fmt'"
@echo " make fmt-check - Run 'go fmt', but don't change anything"
@echo " make vet - Run 'go vet'"
@echo " make lint - Run 'golint'"
@echo " make staticcheck - Run 'staticcheck'"
@echo " make fmt - Run 'go fmt'"
@echo " make fmt-check - Run 'go fmt', but don't change anything"
@echo " make vet - Run 'go vet'"
@echo " make lint - Run 'golint'"
@echo " make staticcheck - Run 'staticcheck'"
@echo
@echo "Build:"
@echo " make build - Build"
@echo " make build-snapshot - Build snapshot"
@echo " make build-simple - Build (using go build, without goreleaser)"
@echo " make clean - Clean build folder"
@echo
@echo "Releasing (requires goreleaser):"
@echo " make release - Create a release"
@echo " make release-snapshot - Create a test release"
@echo "Releasing:"
@echo " make release - Create a release"
@echo " make release-snapshot - Create a test release"
@echo
@echo "Install locally (requires sudo):"
@echo " make install - Copy binary from dist/ to /usr/bin"
@echo " make install-deb - Install .deb from dist/"
@echo " make install-lint - Install golint"
@echo " make install-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
@echo " make install-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
@echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
@echo " make install-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
@echo " make install-deb-amd64 - Install .deb from dist/ (amd64 only)"
@echo " make install-deb-armv6 - Install .deb from dist/ (armv6 only)"
@echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)"
@echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
# Building everything
clean: .PHONY
rm -rf dist build server/docs server/site
build: web docs server
# Documentation
docs: docs-deps docs-build
docs-deps: .PHONY
pip3 install -r requirements.txt
docs: docs-deps
docs-build: .PHONY
mkdocs build
# Web app
web: web-deps web-build
web-deps:
cd web \
&& npm install \
&& node_modules/svgo/bin/svgo src/img/*.svg
cd web && npm install
# If this fails for .svg files, optimizes them with svgo
web-build:
cd web \
@@ -63,7 +98,40 @@ web-build:
../server/site/config.js \
../server/site/asset-manifest.json
web: web-deps web-build
# Main server/client build
server: server-deps
goreleaser build --snapshot --rm-dist --debug
server-amd64: server-deps-static-sites
goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64
server-armv6: server-deps-static-sites server-deps-gcc-armv6-armv7
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv6
server-armv7: server-deps-static-sites server-deps-gcc-armv6-armv7
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7
server-arm64: server-deps-static-sites server-deps-gcc-arm64
goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64
server-deps: server-deps-static-sites server-deps-all server-deps-gcc
server-deps-gcc: server-deps-gcc-armv6-armv7 server-deps-gcc-arm64
server-deps-static-sites:
mkdir -p server/docs server/site
touch server/docs/index.html server/site/app.html
server-deps-all:
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
server-deps-gcc-armv6-armv7:
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
server-deps-gcc-arm64:
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
# Test/check targets
@@ -114,64 +182,55 @@ staticcheck: .PHONY
rm -rf build/staticcheck
# Building targets
build-deps: docs web
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/v7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
build: build-deps
goreleaser build --rm-dist --debug
build-snapshot: build-deps
goreleaser build --snapshot --rm-dist --debug
build-simple: clean
mkdir -p dist/ntfy_linux_amd64 server/docs server/site
touch server/docs/index.html
touch server/site/app.html
export CGO_ENABLED=1
go build \
-o dist/ntfy_linux_amd64/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
clean: .PHONY
rm -rf dist build server/docs server/site
# Releasing targets
release: clean server-deps release-check-tags docs web check
goreleaser release --rm-dist --debug
release-snapshot: clean server-deps docs web check
goreleaser release --snapshot --skip-publish --rm-dist --debug
release-check-tags:
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
if ! grep -q $(LATEST_TAG) docs/install.md; then\
echo "ERROR: Must update docs/install.md with latest tag first.";\
exit 1;\
fi
if grep -q XXXXX docs/releases.md; then\
echo "ERROR: Must update docs/releases.md, found XXXXX.";\
exit 1;\
fi
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
echo "ERROR: Must update docs/releases.mdwith latest tag first.";\
echo "ERROR: Must update docs/releases.md with latest tag first.";\
exit 1;\
fi
release: build-deps release-check-tags check
goreleaser release --rm-dist --debug
release-snapshot: build-deps
goreleaser release --snapshot --skip-publish --rm-dist --debug
# Installing targets
install:
sudo rm -f /usr/bin/ntfy
sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy
install-amd64: remove-binary
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
install-deb:
install-armv6: remove-binary
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
install-armv7: remove-binary
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
install-arm64: remove-binary
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
remove-binary:
sudo rm -f /usr/bin/ntfy
install-amd64-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
install-armv6-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
install-armv7-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
install-arm64-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
purge-package:
sudo systemctl stop ntfy || true
sudo apt-get purge ntfy || true
sudo dpkg -i dist/ntfy_*_linux_amd64.deb

View File

@@ -34,7 +34,14 @@ too.
[Building](https://ntfy.sh/docs/develop/)
## Contributing
I welcome any and all contributions. Just create a PR or an issue.
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
<a href="https://hosted.weblate.org/engage/ntfy/">
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
</a>
## Contact me
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)

View File

@@ -56,6 +56,12 @@ func WithClick(url string) PublishOption {
return WithHeader("X-Click", url)
}
// WithActions adds custom user actions to the notification. The value can be either a JSON array or the
// simple format definition. See https://ntfy.sh/docs/publish/#action-buttons for details.
func WithActions(value string) PublishOption {
return WithHeader("X-Actions", value)
}
// WithAttach sets a URL that will be used by the client to download an attachment
func WithAttach(attach string) PublishOption {
return WithHeader("X-Attach", attach)

View File

@@ -26,6 +26,7 @@ var cmdPublish = &cli.Command{
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
@@ -72,6 +73,7 @@ func execPublish(c *cli.Context) error {
tags := c.String("tags")
delay := c.String("delay")
click := c.String("click")
actions := c.String("actions")
attach := c.String("attach")
filename := c.String("filename")
file := c.String("file")
@@ -112,6 +114,9 @@ func execPublish(c *cli.Context) error {
if click != "" {
options = append(options, client.WithClick(click))
}
if actions != "" {
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
}
if attach != "" {
options = append(options, client.WithAttach(attach))
}

View File

@@ -346,7 +346,7 @@ statuspage.io (though these days most services also support webhooks and HTTP ca
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below)
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
accepted (which may obviously be a spam problem).
@@ -369,6 +369,42 @@ configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
<figcaption>DNS records for incoming mail</figcaption>
</figure>
You can check if everything is working correctly by sending an email as raw SMTP via `nc`. Create a text file, e.g.
`email.txt`
```
EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Subject: Email for you
Content-Type: text/plain; charset="UTF-8"
Hello from 🇩🇪
.
```
And then send the mail via `nc` like this. If you see any lines starting with `451`, those are errors from the
ntfy server. Read them carefully.
```
$ cat email.txt | nc -N ntfy.sh 25
220 ntfy.sh ESMTP Service Ready
250-Hello example.com
...
250 2.0.0 Roger, accepting mail from <phil@example.com>
250 2.0.0 I'll make sure <ntfy-mytopic@ntfy.sh> gets this
```
As for the DNS setup, be sure to verify that `dig MX` and `dig A` are returning results similar to this:
```
$ dig MX ntfy.sh +short
10 mx1.ntfy.sh.
$ dig A mx1.ntfy.sh +short
3.139.215.220
```
## Behind a proxy (TLS, etc.)
!!! warning
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
@@ -483,19 +519,27 @@ or the root domain:
```
<VirtualHost *:80>
ServerName ntfy.sh
SetEnv proxy-nokeepalive 1
SetEnv proxy-sendchunked 1
# Proxy connections to ntfy (requires "a2enmod proxy")
ProxyPass / http://127.0.0.1:2586/
ProxyPassReverse / http://127.0.0.1:2586/
SetEnv proxy-nokeepalive 1
SetEnv proxy-sendchunked 1
# Higher than the max message size of 4096 bytes
LimitRequestBody 102400
# Enable mod_rewrite (requires "a2enmod rewrite")
RewriteEngine on
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
# it to work with curl without the annoying https:// prefix
RewriteEngine on
RewriteCond %{REQUEST_METHOD} GET
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
</VirtualHost>
@@ -507,21 +551,24 @@ or the root domain:
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
SetEnv proxy-nokeepalive 1
SetEnv proxy-sendchunked 1
# Proxy connections to ntfy (requires "a2enmod proxy")
ProxyPass / http://127.0.0.1:2586/
ProxyPassReverse / http://127.0.0.1:2586/
SetEnv proxy-nokeepalive 1
SetEnv proxy-sendchunked 1
# Higher than the max message size of 4096 bytes
LimitRequestBody 102400
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
# it to work with curl without the annoying https:// prefix
# Enable mod_rewrite (requires "a2enmod rewrite")
RewriteEngine on
RewriteCond %{REQUEST_METHOD} GET
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
</VirtualHost>
```
@@ -532,6 +579,15 @@ or the root domain:
ntfy.sh, http://nfty.sh {
reverse_proxy 127.0.0.1:2586
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
# it to work with curl without the annoying https:// prefix
@httpget {
protocol http
method GET
path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/)
}
redir @httpget https://{host}{uri}
}
```

View File

@@ -10,6 +10,10 @@ This page is used to list deprecation notices for ntfy. Deprecated commands and
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely
seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to
WebSocket in June.
### Android app: Using `since=<timestamp>` instead of `since=<id>`
> Active since 2022-02-27, behavior will change in **May 2022**

View File

@@ -1,51 +1,287 @@
# Building
# Development
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
## ntfy server
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy).
To quickly build on amd64, you can use `make build-simple`:
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
server consists of three components:
```
git clone git@github.com:binwiederhier/ntfy.git
cd ntfy
make build-simple
* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at
[main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is
in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a
[SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires
[Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below).
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
build the docs.
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
and install all the 100,000 dependencies (*sigh*).
All of these components are built and then **baked into one binary**.
### Navigating the code
Code:
* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client
* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish`
* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic
* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml`
* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json`
Build related:
* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building
* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/))
* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file
* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/))
* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm)
The `web/` and `docs/` folder are the sources for web app and documentation. During the build process,
the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).
### Build requirements
* [Go](https://go.dev/) (required for main server)
* [gcc](https://gcc.gnu.org/) (required main server, for SQLite cgo-based bindings)
* [Make](https://www.gnu.org/software/make/) (required for convenience)
* [libsqlite3/libsqlite3-dev](https://www.sqlite.org/) (required for main server, for SQLite cgo-based bindings)
* [GoReleaser](https://goreleaser.com/) (required for a proper main server build)
* [Python](https://www.python.org/) (for `pip`, only to build the docs)
* [nodejs](https://nodejs.org/en/) (for `npm`, only to build the web app)
### Install dependencies
These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
``` shell
wget https://go.dev/dl/go1.18.linux-amd64.tar.gz
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
go version # verifies that it worked
```
That'll generate a statically linked binary in `dist/ntfy_linux_amd64/ntfy`.
For all other platforms (including Docker), and for production or other snapshot builds, you should use the amazingly
awesome [GoReleaser](https://goreleaser.com/) make targets:
```
Build:
make build - Build
make build-snapshot - Build snapshot
make build-simple - Build (using go build, without goreleaser)
make clean - Clean build folder
Releasing (requires goreleaser):
make release - Create a release
make release-snapshot - Create a test release
Install [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)):
``` shell
go install github.com/goreleaser/goreleaser@latest
goreleaser -v # verifies that it worked
```
There are currently no platform-specific make targets, so they will build for all platforms (which may take a while).
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
``` shell
curl -fsSL https://deb.nodesource.com/setup_17.x | sudo -E bash -
sudo apt-get install -y nodejs
npm -v # verifies that it worked
```
Then install a few other things required:
``` shell
sudo apt install \
build-essential \
libsqlite3-dev \
gcc-arm-linux-gnueabi \
gcc-aarch64-linux-gnu \
python3-pip \
upx \
git
```
### Check out code
Now check out via git from the [GitHub repository](https://github.com/binwiederhier/ntfy):
=== "via HTTPS"
``` shell
git clone https://github.com/binwiederhier/ntfy.git
cd ntfy
```
=== "via SSH"
``` shell
git clone git@github.com:binwiederhier/ntfy.git
cd ntfy
```
### Build all the things
Now you can finally build everything. There are tons of `make` targets, so maybe just review what's there first
by typing `make`:
``` shell
$ make
Typical commands (more see below):
make build - Build web app, documentation and server/client (sloowwww)
make server-amd64 - Build server/client binary (amd64, no web app or docs)
make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
make web - Build the web app
make docs - Build the documentation
make check - Run all tests, vetting/formatting checks and linters
...
```
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and amd64),
you can simply run `make build`:
``` shell
$ make build
...
# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64).
# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?
```
You'll see all the outputs in the `dist/` folder afterwards:
``` bash
$ find dist
dist
dist/metadata.json
dist/ntfy_arm64_linux_arm64
dist/ntfy_arm64_linux_arm64/ntfy
dist/ntfy_armv7_linux_arm_7
dist/ntfy_armv7_linux_arm_7/ntfy
dist/ntfy_amd64_linux_amd64
dist/ntfy_amd64_linux_amd64/ntfy
dist/config.yaml
dist/artifacts.json
```
If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can
use the `make release-snapshot` target:
``` shell
$ make release-snapshot
...
# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)
```
During development, you may want to be more picky and build only certain things. Here are a few examples.
### Build the ntfy binary
To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets:
``` shell
$ make
Build server & client (not release version):
make server - Build server & client (all architectures)
make server-amd64 - Build server & client (amd64 only)
make server-armv7 - Build server & client (armv7 only)
make server-arm64 - Build server & client (arm64 only)
```
So if you're on an amd64/x86_64-based machine, you may just want to run `make server-amd64` during testing. On a modern
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-amd64` so I can run the binary
right away:
``` shell
$ make server-amd64 install-amd64
$ ntfy serve
```
**During development of the main app, you can also just use `go run main.go`**, as long as you run
`make server-deps-static-sites`at least once and `CGO_ENABLED=1`:
``` shell
$ export CGO_ENABLED=1
$ make server-deps-static-sites
$ go run main.go serve
2022/03/18 08:43:55 Listening on :2586[http]
...
```
If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
```
$ go run main.go serve
server/server.go:85:13: pattern docs: no matching files found
```
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `server-deps-static-sites`
target creates dummy files that ensures that you'll be able to build.
### Build the web app
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
is really simple. Just type `make web` and you're in business:
``` shell
$ make web
...
```
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
that when you `make server` (or `make server-amd64`, ...), you will have the web app included in the `ntfy` binary.
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
will automatically refresh:
``` shell
$ cd web
$ npm start
```
### Build the docs
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
``` shell
$ make docs
...
```
If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation,
serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files:
```
$ mkdocs serve
INFO - Building documentation...
INFO - Cleaning site directory
INFO - Documentation built in 5.53 seconds
INFO - [16:28:14] Serving on http://127.0.0.1:8000/
```
Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.
## Android app
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
The Android app has two flavors:
* **Google Play:** The `play` flavor includes Firebase (FCM) and requires a Firebase account
* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
### Navigating the code
* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code
* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code
* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs
* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file
### IDE/Environment
You should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/)
with the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀
### Check out the code
First check out the repository:
```
git clone git@github.com:binwiederhier/ntfy-android.git
cd ntfy-android
```
=== "via HTTPS"
``` shell
git clone https://github.com/binwiederhier/ntfy-android.git
cd ntfy-android
```
=== "via SSH"
``` shell
git clone git@github.com:binwiederhier/ntfy-android.git
cd ntfy-android
```
Then either follow the steps for building with or without Firebase.
### Building without Firebase (F-Droid flavor)
### Build F-Droid flavor (no FCM)
!!! info
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
work without issues. Please give me feedback if it does/doesn't work for you.
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
if you're self-hosting the server. Then run:
```
@@ -56,8 +292,13 @@ if you're self-hosting the server. Then run:
./gradlew bundleFdroidRelease
```
### Building with Firebase (FCM, Google Play flavor)
### Build Play flavor (FCM)
!!! info
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
work without issues. Please give me feedback if it does/doesn't work for you.
To build your own version with Firebase, you must:
* Create a Firebase/FCM account
* Place your account file at `app/google-services.json`
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)

View File

@@ -124,3 +124,245 @@ GitHub have been hopeless. In case it ever becomes available, I want to know imm
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
~
```
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
## Node-RED
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
<details>
<summary>Example: Send a message (click to expand)</summary>
```
[
{
"id": "c956e688cc74ad8e",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "ntfy.sh",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://ntfy.sh/mytopic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 590,
"y": 3160,
"wires":
[
[]
]
},
{
"id": "32ee1eade51fae50",
"type": "function",
"z": "fabdd7a3.4045a",
"name": "data",
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 3160,
"wires":
[
[
"c956e688cc74ad8e"
]
]
},
{
"id": "b287e59cd2311815",
"type": "inject",
"z": "fabdd7a3.4045a",
"name": "Manual start",
"props":
[
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 330,
"y": 3160,
"wires":
[
[
"32ee1eade51fae50"
]
]
}
]
```
</details>
![Node red message flow](static/img/nodered-message.png)
<details>
<summary>Example: Send a picture (click to expand)</summary>
```
[
{
"id": "d135a13eadeb9d6d",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "Download image",
"method": "GET",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 490,
"y": 3320,
"wires":
[
[
"6e75bc41d2ec4a03"
]
]
},
{
"id": "6e75bc41d2ec4a03",
"type": "function",
"z": "fabdd7a3.4045a",
"name": "data",
"func": "msg.payload = msg.payload;\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 650,
"y": 3320,
"wires":
[
[
"eb160615b6ceda98"
]
]
},
{
"id": "eb160615b6ceda98",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "ntfy.sh",
"method": "PUT",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://ntfy.sh/mytopic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 770,
"y": 3320,
"wires":
[
[]
]
},
{
"id": "5b8dbf15c8a7a3a5",
"type": "inject",
"z": "fabdd7a3.4045a",
"name": "Manual start",
"props":
[
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 310,
"y": 3320,
"wires":
[
[
"d135a13eadeb9d6d"
]
]
}
]
```
</details>
![Node red picture flow](static/img/nodered-picture.png)
## Gatus service health check
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
```
alerting:
custom:
url: "https://ntfy.sh"
method: "POST"
body: |
{
"topic": "mytopic",
"message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]",
"title": "Gatus",
"tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"],
"priority": 3
}
default-alert:
enabled: true
description: "health check failed"
send-on-resolved: true
failure-threshold: 3
success-threshold: 3
placeholders:
ALERT_TRIGGERED_OR_RESOLVED:
TRIGGERED: "warning"
RESOLVED: "white_check_mark"
```

View File

@@ -26,23 +26,38 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_x86_64.tar.gz
tar zxvf ntfy_1.22.0_linux_x86_64.tar.gz
sudo cp -a ntfy_1.22.0_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv6.tar.gz
tar zxvf ntfy_1.22.0_linux_armv6.tar.gz
sudo cp -a ntfy_1.22.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv7.tar.gz
tar zxvf ntfy_1.22.0_linux_armv7.tar.gz
sudo cp -a ntfy_1.22.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_arm64.tar.gz
tar zxvf ntfy_1.22.0_linux_arm64.tar.gz
sudo cp -a ntfy_1.22.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.22.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
## Debian/Ubuntu repository
@@ -88,7 +103,15 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -96,7 +119,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -104,7 +127,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -114,21 +137,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.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/v1.18.0/ntfy_1.18.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.18.0/ntfy_1.18.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.22.0/ntfy_1.22.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -146,10 +176,9 @@ cd ntfysh-bin
makepkg -si
```
## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
straight forward to use.
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
be pretty straight forward to use.
The server exposes its web UI and the API on port 80, so you need to expose that in Docker. To use the persistent
[message cache](config.md#message-cache), you also need to map a volume to `/var/cache/ntfy`. To change other settings,
@@ -181,6 +210,24 @@ docker run \
serve
```
Using docker-compose:
```yaml
version: "2.1"
services:
ntfy:
image: binwiederhier/ntfy
container_name: ntfy
command:
- serve
volumes:
- /var/cache/ntfy:/var/cache/ntfy
- /etc/ntfy:/etc/ntfy
ports:
- 80:80
restart: unless-stopped
```
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
```
FROM binwiederhier/ntfy
@@ -188,13 +235,3 @@ COPY server.yml /etc/ntfy/server.yml
ENTRYPOINT ["ntfy", "serve"]
```
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
## Go
To install via Go, simply run:
```bash
go install heckel.io/ntfy@latest
```
!!! info
Please [let me know](https://github.com/binwiederhier/ntfy/issues) if there are any issues with this installation
method. The SQLite bindings require CGO and it works for me, but I have the feeling it may not work for everyone.

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,267 @@
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 Android app v1.13.0 (UNRELEASED)
**Features:**
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@cmeis](https://github.com/cmeis) for reporting)
**Bugs:**
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)
* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
* Restoring topics or settings from backup doesn't work ([#223](https://github.com/binwiederhier/ntfy/issues/223), thanks to [@shadow00](https://github.com/shadow00) for reporting)
* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)
* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)
* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))
* Prevent long topic names and icons from overlappng ([#240](https://github.com/binwiederhier/ntfy/issues/240), thanks to [@cmeis](https://github.com/cmeis) for reporting)
**Additional translations:**
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony/))
**Thanks for testing:**
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
to [@Joeharrison94](https://github.com/Joeharrison94) for the input.
-->
## ntfy server v1.22.0
Released May 7, 2022
This release makes the web app more accessible to people with disabilities, and introduces a "mark as read" icon in the web app.
It also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter.
We've also improved the documentation a little and added translations for three more languages.
**Features:**
* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
* Better parsing of the user actions, allowing quotes (no ticket)
* Add "mark as read" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8))
**Bugs:**
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)
**Documentation:**
* Improved caddy configuration (no ticket, thanks to @Stnby)
* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))
* Fixed PowerShell auth example to use UTF-8 ([#242](https://github.com/binwiederhier/ntfy/pull/242), thanks to [@SMAW](https://github.com/SMAW))
**Additional translations:**
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
* Hungarian (thanks to [@agocsdaniel](https://hosted.weblate.org/user/agocsdaniel/))
**Thanks for testing:**
Thanks to [@wunter8](https://github.com/wunter8) for testing.
## ntfy Android app v1.12.0
Released Apr 25, 2022
The main feature in this Android release is [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons), a feature
that allows users to add actions to the notifications. Actions can be to view a website or app, send a broadcast, or
send a HTTP request.
We also added support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links), added three more
languages and fixed a ton of bugs.
**Features:**
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
thanks to [@mrherman](https://github.com/mrherman) for reporting)
* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks
to [@Copephobia](https://github.com/Copephobia) for reporting)
* [Fastlane metadata](https://hosted.weblate.org/projects/ntfy/android-fastlane/) can now be translated too ([#198](https://github.com/binwiederhier/ntfy/issues/198),
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))
**Bugs:**
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209),
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
* "[x] Instant delivery in doze mode" checkbox does not work properly ([#211](https://github.com/binwiederhier/ntfy/issues/211))
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
[@cmeis](https://github.com/cmeis) for reporting)
* Action "view" with "clear=true" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to
[@cmeis](https://github.com/cmeis) for reporting)
* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to
[@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting)
**Additional translations:**
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))
* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))
**Thanks for testing:**
Thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) (aka @Shard), [@cmeis](https://github.com/cmeis),
@poblabs, and everyone I forgot for testing.
## ntfy server v1.21.2
Released Apr 24, 2022
In this release, the web app got translation support and was translated into 9 languages already 🇧🇬 🇩🇪 🇺🇸 🌎.
It also re-adds support for ARMv6, and adds server-side support for Action Buttons. [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons)
is a feature that will be released in the Android app soon. It allows users to add actions to the notifications.
Limited support is available in the web app.
**Features:**
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
thanks to [@mrherman](https://github.com/mrherman) for reporting)
* Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting)
* Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189))
**Bugs:**
* Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
* Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis))
* Web app: basic URL validation in user management ([#204](https://github.com/binwiederhier/ntfy/issues/204), thanks to [@cmeis](https://github.com/cmeis))
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
[@cmeis](https://github.com/cmeis) for reporting)
**Translations (web app):**
* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
* German (thanks to [@cmeis](https://github.com/cmeis))
* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))
* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))
* Norwegian Bokmål (thanks to [@comradekingu](https://github.com/comradekingu))
* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))
* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))
* Turkish (thanks to [@ersen](https://ersen.moe/))
**Integrations:**
[Apprise](https://github.com/caronc/apprise) support was fully released in [v0.9.8.2](https://github.com/caronc/apprise/releases/tag/v0.9.8.2)
of Apprise. Thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work.
You can try it yourself like this (detailed usage in the [Apprise wiki](https://github.com/caronc/apprise/wiki/Notify_ntfy)):
```
pip3 install apprise
apprise -b "Hi there" ntfys://mytopic
```
## ntfy Android app v1.11.0
Released Apr 7, 2022
**Features:**
* Download attachments to cache folder ([#181](https://github.com/binwiederhier/ntfy/issues/181))
* Regularly delete attachments for deleted notifications ([#142](https://github.com/binwiederhier/ntfy/issues/142))
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
**Bugs:**
* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
* Refresh preferences screen after settings import (#183, thanks to [@cmeis](https://github.com/cmeis) for reporting)
* Add priority strings to strings.xml to make it translatable (#192, thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
**Translations:**
* English language improvements (thanks to [@comradekingu](https://github.com/comradekingu))
* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
* Chinese/Simplified (thanks to [@poi](https://hosted.weblate.org/user/poi) and [@PeterCxy](https://hosted.weblate.org/user/PeterCxy))
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony))
* French (thanks to [@Kusoneko](https://kusoneko.moe/) and [@mlcsthor](https://hosted.weblate.org/user/mlcsthor/))
* German (thanks to [@cmeis](https://github.com/cmeis))
* Italian (thanks to [@theTranslator](https://hosted.weblate.org/user/theTranslator/))
* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))
* Norwegian Bokmål (*incomplete*, thanks to [@comradekingu](https://github.com/comradekingu))
* Portuguese/Brazil (thanks to [@LW](https://hosted.weblate.org/user/LW/))
* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))
* Turkish (thanks to [@ersen](https://ersen.moe/))
**Thanks:**
* Many thanks to [@cmeis](https://github.com/cmeis), [@Fallenbagel](https://github.com/Fallenbagel), [@Joeharrison94](https://github.com/Joeharrison94),
and [@rogeliodh](https://github.com/rogeliodh) for input on the new attachment logic, and for testing the release
## ntfy server v1.20.0
Released Apr 6, 2022
**Features:**:
* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196))
**Bugs:**
* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))
**Documentation:**
* Added docker-compose example to [install instructions](install.md#docker) ([#194](https://github.com/binwiederhier/ntfy/pull/194), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))
**Integrations:**
* [Apprise](https://github.com/caronc/apprise) has added integration into ntfy ([#99](https://github.com/binwiederhier/ntfy/issues/99), [apprise#524](https://github.com/caronc/apprise/pull/524),
thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work)
## ntfy server v1.19.0
Released Mar 30, 2022
**Bugs:**
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
* Add "Access-Control-Allow-Origin: *" for attachments (no ticket, thanks to @FrameXX)
* Make pruning run again in web app ([#186](https://github.com/binwiederhier/ntfy/issues/186))
* Added missing params `delay` and `email` to publish as JSON body (no ticket)
**Documentation:**
* Improved [e-mail publishing](config.md#e-mail-publishing) documentation
## ntfy server v1.18.1
Released Mar 21, 2022
_This release ships no features or bug fixes. It's merely a documentation update._
**Documentation:**
* Overhaul of [developer documentation](https://ntfy.sh/docs/develop/)
* PowerShell examples for [publish documentation](https://ntfy.sh/docs/publish/) ([#138](https://github.com/binwiederhier/ntfy/issues/138), thanks to [@Joeharrison94](https://github.com/Joeharrison94))
* Additional examples for [NodeRED, Gatus, Sonarr, Radarr, ...](https://ntfy.sh/docs/examples/) (thanks to [@nickexyz](https://github.com/nickexyz))
* Fixes in developer instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
## ntfy Android app v1.10.0
Released Mar 21, 2022
**Features:**
* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130))
* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting)
* Open "Click" link when tapping notification ([#110](https://github.com/binwiederhier/ntfy/issues/110), thanks [@cmeis](https://github.com/cmeis) for reporting)
* JSON stream deprecation banner ([#164](https://github.com/binwiederhier/ntfy/issues/164))
**Bug fixes:**
* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting)
## ntfy server v1.18.0
Released Mar 16, 2022
**Features:**
* Publish messages as JSON ([#133](https://github.com/binwiederhier/ntfy/issues/133), thanks [@cmeis](https://github.com/cmeis) for reporting)
* [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133),
thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and
[@Fallenbagel](https://github.com/Fallenbagel) for testing)
**Bug fixes:**
@@ -17,20 +272,8 @@ Released Mar 16, 2022
**Deprecations:**
* Removed the ability to run server as `ntfy serve` as per [deprecation](deprecations.md)
* Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md)
<!--
## ntfy Android app v1.10.0 (UNRELEASED)
**Features:**
* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130))
* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting)
**Bug fixes:**
* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting)
-->
## ntfy server v1.17.1
Released Mar 12, 2022

View File

@@ -8,6 +8,10 @@
width: unset !important;
}
.md-header__topic:first-child {
font-weight: 400;
}
.md-typeset h4 {
font-weight: 500 !important;
margin: 0 !important;

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
docs/static/img/nodered-message.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
docs/static/img/nodered-picture.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -31,7 +31,7 @@ If those screenshots are still not enough, here's a video:
</figure>
## Message priority
When you [publish messages](../publish.md#message-priority) to a topic, you can define a priority. This priority defines
When you [publish messages](../publish.md#message-priority) to a topic, you can **define a priority**. This priority defines
how urgently Android will notify you about the notification, and whether they make a sound and/or vibrate.
By default, messages with default priority or higher (>= 3) will vibrate and make a sound. Messages with high or urgent
@@ -42,11 +42,19 @@ priority (>= 4) will also show as pop-over, like so:
<figcaption>High and urgent notifications show as pop-over</figcaption>
</figure>
You can change these settings in Android by long-pressing on the app, and tapping "Notifications". You can then configure
the settings (and custom sounds or vibration) for each of the priorities:
You can change these settings in Android by long-pressing on the app, and tapping "Notifications", or from the "Settings"
menu under "Channel settings". There is one notification channel for each priority:
<figure markdown>
![notification settings](../static/img/android-notification-settings.png){ width=500 }
![notification settings](../static/img/android-screenshot-notification-settings.png){ width=500 }
<figcaption>Per-priority channels</figcaption>
</figure>
Per notification channel, you can configure a **channel-specific sound**, whether to **override the Do Not Disturb (DND)**
setting, and other settings such as popover or notification dot:
<figure markdown>
![channel details](../static/img/android-screenshot-notification-details.jpg){ width=500 }
<figcaption>Per-priority sound/vibration settings</figcaption>
</figure>
@@ -80,6 +88,34 @@ notifications. Firebase is overall pretty bad at delivering messages in time, bu
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
## Share to topic
You can share files to a topic using Android's "Share" feature. This works in almost any app that supports sharing files
or text, and it's useful for sending yourself links, files or other things. The feature remembers a few of the last topics
you shared content to and lists them at the bottom.
The feature is pretty self-explanatory, and one picture says more than a thousand words. So here are two pictures:
<div id="share-to-topic-screenshots" class="screenshots">
<a href="../../static/img/android-screenshot-share-1.jpg"><img src="../../static/img/android-screenshot-share-1.jpg"/></a>
<a href="../../static/img/android-screenshot-share-2.jpg"><img src="../../static/img/android-screenshot-share-2.jpg"/></a>
</div>
## ntfy:// links
The ntfy Android app supports deep linking directly to topics. This is useful when integrating with [automation apps](#automation-apps)
such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm),
or to simply directly link to a topic from a mobile website.
!!! info
Android deep linking of http/https links is very brittle and limited, which is why something like `https://<host>/<topic>/subscribe` is
**not possible**, and instead `ntfy://` links have to be used. More details in [issue #20](https://github.com/binwiederhier/ntfy/issues/20).
**Supported link formats:**
| Link format | Example | Description |
|-------------------------------------------------------------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <span style="white-space: nowrap">`ntfy://<host>/<topic>`</span> | `ntfy://ntfy.sh/mytopic` | Directly opens the Android app detail view for the given topic and server. Subscribes to the topic if not already subscribed. This is equivalent to the web view `https://ntfy.sh/mytopic` (HTTPS!) |
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
## Integrations
### UnifiedPush

40
go.mod
View File

@@ -4,9 +4,9 @@ go 1.17
require (
cloud.google.com/go/firestore v1.6.1 // indirect
cloud.google.com/go/storage v1.21.0 // indirect
cloud.google.com/go/storage v1.22.0 // indirect
firebase.google.com/go v3.13.0+incompatible
github.com/BurntSushi/toml v1.0.0 // indirect
github.com/BurntSushi/toml v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/gabriel-vasile/mimetype v1.4.0
@@ -14,38 +14,40 @@ require (
github.com/mattn/go-sqlite3 v1.14.12
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.4.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
github.com/urfave/cli/v2 v2.4.7
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
google.golang.org/api v0.73.0
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171
golang.org/x/time v0.0.0-20220411224347-583f2d630306
google.golang.org/api v0.75.0
gopkg.in/yaml.v2 v2.4.0
)
require github.com/pkg/errors v0.9.1 // indirect
require (
cloud.google.com/go v0.100.2 // indirect
cloud.google.com/go/compute v1.5.0 // indirect
cloud.google.com/go v0.101.0 // indirect
cloud.google.com/go/compute v1.6.1 // indirect
cloud.google.com/go/iam v0.3.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.7 // indirect
github.com/googleapis/gax-go/v2 v2.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/googleapis/gax-go/v2 v2.3.0 // indirect
github.com/googleapis/go-type-adapters v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 // indirect
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect
google.golang.org/grpc v1.45.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 // indirect
google.golang.org/grpc v1.46.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

96
go.sum
View File

@@ -26,9 +26,9 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go v0.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84=
cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -36,15 +36,15 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM=
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc=
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
@@ -56,16 +56,17 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14=
cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
cloud.google.com/go/storage v1.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8=
cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
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=
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@@ -83,6 +84,7 @@ github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XP
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -102,6 +104,7 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
@@ -163,8 +166,9 @@ github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPg
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -185,8 +189,11 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
@@ -224,8 +231,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I=
github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
github.com/urfave/cli/v2 v2.4.7 h1:nUgKLTC/InVYwUx26HZUBGIBZaptiW97W8vVlhuYawo=
github.com/urfave/cli/v2 v2.4.7/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -244,8 +251,9 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -317,9 +325,13 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 h1:yssD99+7tqHWO5Gwh81phT+67hg+KttniBr6UnEXOY8=
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -338,8 +350,9 @@ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
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-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -402,15 +415,17 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -424,8 +439,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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=
@@ -480,8 +495,9 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -514,14 +530,12 @@ google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdr
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
google.golang.org/api v0.73.0 h1:O9bThUh35K1rvUrQwTUQ1eqLC/IYyzUpWavYIO2EXvo=
google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
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.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -569,6 +583,7 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
@@ -592,20 +607,20 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 h1:ErU+UA6wxadoU8nWrsy5MZUVBs75K17zUCsUCIfrXCE=
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE=
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -633,8 +648,9 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
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=
@@ -648,15 +664,15 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
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.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=

View File

@@ -1,9 +1,3 @@
# The documentation uses 'mkdocs', which is written in Python
# See https://github.com/squidfunk/mkdocs-material/issues/2030
jinja2>=2.11.1
# mkdocs
mkdocs
mkdocs-material
mkdocs-minify-plugin

View File

@@ -10,7 +10,7 @@ if [ -z "$1" ]; then
echo "Syntax: $0 FILE.(js|json|md)"
echo "Example:"
echo " $0 emoji-converted.json"
echo " $0 $ROOTDIR/server/static/js/emoji.js"
echo " $0 $ROOTDIR/web/src/app/emojis.js"
echo " $0 $ROOTDIR/docs/emojis.md"
exit 1
fi
@@ -19,7 +19,7 @@ if [[ "$1" == *.js ]]; then
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
export const rawEmojis = " > "$1"
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji, aliases: .aliases, tags: .tags, category: .category, description: .description, unicode_version: .unicode_version})' >> "$1"
elif [[ "$1" == *.md ]]; then
echo "# Emoji reference

307
server/actions.go Normal file
View File

@@ -0,0 +1,307 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/util"
"regexp"
"strings"
"unicode/utf8"
)
const (
actionIDLength = 10
actionEOF = rune(0)
actionsMax = 3
)
const (
actionView = "view"
actionBroadcast = "broadcast"
actionHTTP = "http"
)
var (
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
actionsWithURL = []string{actionView, actionHTTP}
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
)
type actionParser struct {
input string
pos int
}
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
func parseActions(s string) (actions []*action, err error) {
// Parse JSON or simple format
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "[") {
actions, err = parseActionsFromJSON(s)
} else {
actions, err = parseActionsFromSimple(s)
}
if err != nil {
return nil, err
}
// Add ID field, ensure correct uppercase/lowercase
for i := range actions {
actions[i].ID = util.RandomString(actionIDLength)
actions[i].Action = strings.ToLower(actions[i].Action)
actions[i].Method = strings.ToUpper(actions[i].Method)
}
// Validate
if len(actions) > actionsMax {
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
}
for _, action := range actions {
if !util.InStringList(actionsAll, action.Action) {
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
} else if action.Label == "" {
return nil, fmt.Errorf("parameter 'label' is required")
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
}
}
return actions, nil
}
// parseActionsFromJSON converts a JSON array into an array of actions
func parseActionsFromJSON(s string) ([]*action, error) {
actions := make([]*action, 0)
if err := json.Unmarshal([]byte(s), &actions); err != nil {
return nil, fmt.Errorf("JSON error: %w", err)
}
return actions, nil
}
// parseActionsFromSimple parses the "simple" actions string (as described in
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
//
// It can parse an actions string like this:
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
//
// It works by advancing the position ("pos") through the input string ("input").
//
// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
// though it does not use state functions at all.
//
// Other resources:
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
func parseActionsFromSimple(s string) ([]*action, error) {
if !utf8.ValidString(s) {
return nil, errors.New("invalid utf-8 string")
}
parser := &actionParser{
pos: 0,
input: s,
}
return parser.Parse()
}
// Parse loops trough parseAction() until the end of the string is reached
func (p *actionParser) Parse() ([]*action, error) {
actions := make([]*action, 0)
for !p.eof() {
a, err := p.parseAction()
if err != nil {
return nil, err
}
actions = append(actions, a)
}
return actions, nil
}
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
// and then uses populateAction to interpret the keys/values. The function terminates
// when EOF or ";" is reached.
func (p *actionParser) parseAction() (*action, error) {
a := newAction()
section := 0
for {
key, value, last, err := p.parseSection()
if err != nil {
return nil, err
}
if err := populateAction(a, section, key, value); err != nil {
return nil, err
}
p.slurpSpaces()
if last {
return a, nil
}
section++
}
}
// populateAction is the "business logic" of the parser. It applies the key/value
// pair to the action instance.
func populateAction(newAction *action, section int, key, value string) error {
// Auto-expand keys based on their index
if key == "" && section == 0 {
key = "action"
} else if key == "" && section == 1 {
key = "label"
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
key = "url"
}
// Validate
if key == "" {
return fmt.Errorf("term '%s' unknown", value)
}
// Populate
if strings.HasPrefix(key, "headers.") {
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
} else if strings.HasPrefix(key, "extras.") {
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
} else {
switch strings.ToLower(key) {
case "action":
newAction.Action = value
case "label":
newAction.Label = value
case "clear":
lvalue := strings.ToLower(value)
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
}
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
case "url":
newAction.URL = value
case "method":
newAction.Method = value
case "body":
newAction.Body = value
default:
return fmt.Errorf("key '%s' unknown", key)
}
}
return nil
}
// parseSection parses a section ("key=value") and returns a key/value pair. It terminates
// when EOF or "," is reached.
func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
p.slurpSpaces()
key = p.parseKey()
r, w := p.peek()
if isSectionEnd(r) {
p.pos += w
last = isLastSection(r)
return
} else if r == '"' || r == '\'' {
value, last, err = p.parseQuotedValue(r)
return
}
value, last = p.parseValue()
return
}
// parseKey uses a regex to determine whether the current position is a key definition ("key =")
// and returns the key if it is, or an empty string otherwise.
func (p *actionParser) parseKey() string {
matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
if len(matches) == 2 {
p.pos += len(matches[0])
return matches[1]
}
return ""
}
// parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
// this function does not support "," or ";" in the value itself, and spaces in the beginning and end of the
// string are trimmed.
func (p *actionParser) parseValue() (value string, last bool) {
start := p.pos
for {
r, w := p.peek()
if isSectionEnd(r) {
last = isLastSection(r)
value = strings.TrimSpace(p.input[start:p.pos])
p.pos += w
return
}
p.pos += w
}
}
// parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
// advances the position beyond the section end. It supports quoting strings using backslash (\).
func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
p.pos++
start := p.pos
var prev rune
for {
r, w := p.peek()
if r == actionEOF {
err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
return
} else if r == quote && prev != '\\' {
value = strings.ReplaceAll(p.input[start:p.pos], "\\"+string(quote), string(quote)) // \" -> "
p.pos += w
// Advance until section end (after "," or ";")
p.slurpSpaces()
r, w := p.peek()
last = isLastSection(r)
if !isSectionEnd(r) {
err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
return
}
p.pos += w
return
}
prev = r
p.pos += w
}
}
// slurpSpaces reads all space characters and advances the position
func (p *actionParser) slurpSpaces() {
for {
r, w := p.peek()
if r == actionEOF || !isSpace(r) {
return
}
p.pos += w
}
}
// peek returns the next run and its width
func (p *actionParser) peek() (rune, int) {
if p.eof() {
return actionEOF, 0
}
return utf8.DecodeRuneInString(p.input[p.pos:])
}
// eof returns true if the end of the input has been reached
func (p *actionParser) eof() bool {
return p.pos >= len(p.input)
}
func isSpace(r rune) bool {
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
}
func isSectionEnd(r rune) bool {
return r == actionEOF || r == ';' || r == ','
}
func isLastSection(r rune) bool {
return r == actionEOF || r == ';'
}

176
server/actions_test.go Normal file
View File

@@ -0,0 +1,176 @@
package server
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestParseActions(t *testing.T) {
actions, err := parseActions("[]")
require.Nil(t, err)
require.Empty(t, actions)
// Basic test
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "view", actions[1].Action)
require.Equal(t, "Show portal", actions[1].Label)
require.Equal(t, "https://door.lan", actions[1].URL)
// JSON
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "view", actions[1].Action)
require.Equal(t, "Show portal", actions[1].Label)
require.Equal(t, "https://door.lan", actions[1].URL)
// Other params
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Open door", actions[0].Label)
require.Equal(t, "https://door.lan/open", actions[0].URL)
require.Equal(t, "PUT", actions[0].Method)
require.Equal(t, "this is a body", actions[0].Body)
// Extras with underscores
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "broadcast", actions[0].Action)
require.Equal(t, "Do a thing", actions[0].Label)
require.Equal(t, 2, len(actions[0].Extras))
require.Equal(t, "some command", actions[0].Extras["command"])
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
// Headers with dashes
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Send request", actions[0].Label)
require.Equal(t, 2, len(actions[0].Headers))
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
// Quotes
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Look ma, "quotes"; and semicolons`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
// Single quotes
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `"quotes" and 'single quotes'`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
// Single quotes (JSON)
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, "Post it", actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
// Out of order
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Out of order!`, actions[0].Label)
require.Equal(t, `http://example.com`, actions[0].URL)
// Spaces
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `this is a label`, actions[0].Label)
require.Equal(t, `http://google.com`, actions[0].URL)
// Non-ASCII
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
require.Nil(t, err)
require.Equal(t, 1, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)
require.Equal(t, `http://google.com`, actions[0].URL)
// Multiple actions, awkward spacing
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
require.Nil(t, err)
require.Equal(t, 2, len(actions))
require.Equal(t, "http", actions[0].Action)
require.Equal(t, `Make love, not war 💙🫤`, actions[0].Label)
require.Equal(t, `https://ntfy.sh`, actions[0].URL)
require.Equal(t, false, actions[0].Clear)
require.Equal(t, "view", actions[1].Action)
require.Equal(t, " yo ", actions[1].Label)
require.Equal(t, `https://x.org`, actions[1].URL)
require.Equal(t, true, actions[1].Clear)
// Invalid syntax
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
require.EqualError(t, err, "unexpected character 'x' at position 22")
_, err = parseActions(`label="", action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
_, err = parseActions(`label=, action="http", url=http://example.com`)
require.EqualError(t, err, "parameter 'label' is required")
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
require.EqualError(t, err, "term 'what is this anyway' unknown")
_, err = parseActions(`fdsfdsf`)
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
require.EqualError(t, err, "key 'aaa' unknown")
_, err = parseActions(`action=http, label="omg the end quote is missing`)
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
_, err = parseActions(`;;;;`)
require.EqualError(t, err, "only 3 actions allowed")
_, err = parseActions(`,,,,,,;;`)
require.EqualError(t, err, "term '' unknown")
_, err = parseActions(`''";,;"`)
require.EqualError(t, err, "unexpected character '\"' at position 2")
_, err = parseActions(`action=http, label=a label, body=somebody`)
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
_, err = parseActions(`[ invalid json ]`)
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
_, err = parseActions(`[ { "some": "object" } ]`)
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
_, err = parseActions("\x00\x01\xFFx\xFE")
require.EqualError(t, err, "invalid utf-8 string")
_, err = parseActions(`http, label, http://x.org, clear=x`)
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
}

View File

@@ -2,6 +2,7 @@ package server
import (
"encoding/json"
"fmt"
"net/http"
)
@@ -22,6 +23,15 @@ func (e errHTTP) JSON() string {
return string(b)
}
func wrapErrHTTP(err *errHTTP, message string, args ...interface{}) *errHTTP {
return &errHTTP{
Code: err.Code,
HTTPCode: err.HTTPCode,
Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)),
Link: err.Link,
}
}
var (
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
@@ -34,15 +44,16 @@ var (
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}

View File

@@ -2,6 +2,7 @@ package server
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -29,6 +30,7 @@ const (
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size INT NOT NULL,
@@ -43,37 +45,37 @@ const (
COMMIT;
`
insertMessageQuery = `
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
selectMessagesSinceTimeQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time, id
`
selectMessagesSinceIDQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id
`
selectMessagesDueQuery = `
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE time <= ? AND published = 0
ORDER BY time, id
@@ -88,7 +90,7 @@ const (
// Schema management queries
const (
currentSchemaVersion = 5
currentSchemaVersion = 6
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -166,6 +168,11 @@ const (
ALTER TABLE messages_new RENAME TO messages;
COMMIT;
`
// 5 -> 6
migrate5To6AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN actions TEXT NOT NULL DEFAULT('');
`
)
type messageCache struct {
@@ -228,6 +235,14 @@ func (c *messageCache) AddMessage(m *message) error {
attachmentURL = m.Attachment.URL
attachmentOwner = m.Attachment.Owner
}
var actionsStr string
if len(m.Actions) > 0 {
actionsBytes, err := json.Marshal(m.Actions)
if err != nil {
return err
}
actionsStr = string(actionsBytes)
}
_, err := c.db.Exec(
insertMessageQuery,
m.ID,
@@ -238,6 +253,7 @@ func (c *messageCache) AddMessage(m *message) error {
m.Priority,
tags,
m.Click,
actionsStr,
attachmentName,
attachmentType,
attachmentSize,
@@ -355,7 +371,7 @@ func (c *messageCache) Prune(olderThan time.Time) error {
return err
}
func (c *messageCache) AttachmentsSize(owner string) (int64, error) {
func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
if err != nil {
return 0, err
@@ -399,7 +415,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
for rows.Next() {
var timestamp, attachmentSize, attachmentExpires int64
var priority int
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
var id, topic, msg, title, tagsStr, click, actionsStr, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
err := rows.Scan(
&id,
&timestamp,
@@ -409,6 +425,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
&priority,
&tagsStr,
&click,
&actionsStr,
&attachmentName,
&attachmentType,
&attachmentSize,
@@ -424,6 +441,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
var actions []*action
if actionsStr != "" {
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
return nil, err
}
}
var att *attachment
if attachmentName != "" && attachmentURL != "" {
att = &attachment{
@@ -445,6 +468,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
Priority: priority,
Tags: tags,
Click: click,
Actions: actions,
Attachment: att,
Encoding: encoding,
})
@@ -490,6 +514,8 @@ func setupCacheDB(db *sql.DB) error {
return migrateFrom3(db)
} else if schemaVersion == 4 {
return migrateFrom4(db)
} else if schemaVersion == 5 {
return migrateFrom5(db)
}
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
}
@@ -562,5 +588,16 @@ func migrateFrom4(db *sql.DB) error {
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
return err
}
return migrateFrom5(db)
}
func migrateFrom5(db *sql.DB) error {
log.Print("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 // Update this when a new version is added
}

View File

@@ -337,11 +337,11 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
size, err := c.AttachmentsSize("1.2.3.4")
size, err := c.AttachmentBytesUsed("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(30000), size)
size, err = c.AttachmentsSize("5.6.7.8")
size, err = c.AttachmentBytesUsed("5.6.7.8")
require.Nil(t, err)
require.Equal(t, int64(0), size)

View File

@@ -55,17 +55,18 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
var (
// If changed, don't forget to update Android App and auth_sqlite.go
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
extTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js"
userStatsPath = "/user/stats"
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
@@ -269,6 +270,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleEmpty(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.handleWebConfig(w, r)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.handleStatic(w, r)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
@@ -293,7 +296,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || extTopicPathRegex.MatchString(r.URL.Path)) {
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
return s.handleTopic(w, r)
}
return errHTTPNotFound
@@ -351,6 +354,19 @@ var config = {
return err
}
func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error {
stats, err := v.Stats()
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(stats); err != nil {
return err
}
return nil
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
@@ -380,6 +396,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
return errHTTPTooManyRequestsAttachmentBandwidthLimit
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
f, err := os.Open(file)
if err != nil {
return err
@@ -394,7 +411,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if err != nil {
return err
}
body, err := util.Peak(r.Body, s.config.MessageLimit)
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return err
}
@@ -518,6 +535,13 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
}
m.Time = delay.Unix()
}
actionsStr := readParam(r, "x-actions", "actions", "action")
if actionsStr != "" {
m.Actions, err = parseActions(actionsStr)
if err != nil {
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
}
}
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush {
firebase = false
@@ -538,35 +562,35 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 5. 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.PeakedReadCloser, unifiedpush bool) error {
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
if unifiedpush {
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
} else if m.Attachment != nil && m.Attachment.URL != "" {
return s.handleBodyAsTextMessage(m, body) // Case 2
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 4
}
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
}
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error {
if utf8.Valid(body.PeakedBytes) {
m.Message = string(body.PeakedBytes) // Do not trim
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
if utf8.Valid(body.PeekedBytes) {
m.Message = string(body.PeekedBytes) // Do not trim
} else {
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes)
m.Message = base64.StdEncoding.EncodeToString(body.PeekedBytes)
m.Encoding = encodingBase64
}
return nil
}
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser) error {
if !utf8.Valid(body.PeakedBytes) {
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
if !utf8.Valid(body.PeekedBytes) {
return errHTTPBadRequestMessageNotUTF8
}
if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!)
m.Message = strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required
if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
}
if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
@@ -574,22 +598,21 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser
return nil
}
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
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
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
}
visitorAttachmentsSize, err := s.messageCache.AttachmentsSize(v.ip)
visitorStats, err := v.Stats()
if err != nil {
return err
}
remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize
contentLengthStr := r.Header.Get("Content-Length")
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
if err == nil && (contentLength > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) {
return errHTTPBadRequestAttachmentTooLarge
if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) {
return errHTTPEntityTooLargeAttachmentTooLarge
}
}
if m.Attachment == nil {
@@ -598,7 +621,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
var ext string
m.Attachment.Owner = v.ip // Important for attachment rate limiting
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
m.Attachment.Type, ext = util.DetectContentType(body.PeakedBytes, m.Attachment.Name)
m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
if m.Attachment.Name == "" {
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
@@ -606,9 +629,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
if m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
}
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize))
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(visitorStats.VisitorAttachmentBytesRemaining))
if err == util.ErrLimitReached {
return errHTTPBadRequestAttachmentTooLarge
return errHTTPEntityTooLargeAttachmentTooLarge
} else if err != nil {
return err
}
@@ -716,7 +739,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
}
func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *visitor) error {
if r.Header.Get("Upgrade") != "websocket" {
if strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
return errHTTPBadRequestWebSocketsUpgradeHeaderMissing
}
if err := v.SubscriptionAllowed(); err != nil {
@@ -1095,11 +1118,11 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
}
}
// transformBodyJSON peaks the request body, reads the JSON, and converts it to headers
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
// 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 {
body, err := util.Peak(r.Body, s.config.MessageLimit)
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return err
}
@@ -1134,6 +1157,19 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
if m.Click != "" {
r.Header.Set("X-Click", m.Click)
}
if len(m.Actions) > 0 {
actionsStr, err := json.Marshal(m.Actions)
if err != nil {
return errHTTPBadRequestJSONInvalid
}
r.Header.Set("X-Actions", string(actionsStr))
}
if m.Email != "" {
r.Header.Set("X-Email", m.Email)
}
if m.Delay != "" {
r.Header.Set("X-Delay", m.Delay)
}
return next(w, r, v)
}
}
@@ -1209,7 +1245,7 @@ func (s *Server) visitor(r *http.Request) *visitor {
}
v, exists := s.visitors[ip]
if !exists {
s.visitors[ip] = newVisitor(s.config, ip)
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
return s.visitors[ip]
}
v.Keepalive()

View File

@@ -81,6 +81,13 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
"message": m.Message,
"encoding": m.Encoding,
}
if len(m.Actions) > 0 {
actions, err := json.Marshal(m.Actions)
if err != nil {
return nil, err
}
data["actions"] = string(actions)
}
if m.Attachment != nil {
data["attachment_name"] = m.Attachment.Name
data["attachment_type"] = m.Attachment.Type

View File

@@ -203,6 +203,14 @@ func TestServer_PublishPriority(t *testing.T) {
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_PublishGETOnlyOneTopic(t *testing.T) {
// This tests a bug that allowed publishing topics with a comma in the name (no ticket)
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil)
require.Equal(t, 404, response.Code)
}
func TestServer_PublishNoCache(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -706,6 +714,12 @@ func (t *testMailer) Send(from, to string, m *message) error {
return nil
}
func (t *testMailer) Count() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.count
}
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for i := 0; i < 60; i++ {
@@ -862,10 +876,31 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
require.Equal(t, "this is a unifiedpush text message", m.Message)
}
func TestServer_PublishActions_AndPoll(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
"Actions": "view, Open portal, https://home.nest.com/; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65",
})
require.Equal(t, 200, response.Code)
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, 2, len(m.Actions))
require.Equal(t, "view", m.Actions[0].Action)
require.Equal(t, "Open portal", m.Actions[0].Label)
require.Equal(t, "https://home.nest.com/", m.Actions[0].URL)
require.Equal(t, "http", m.Actions[1].Action)
require.Equal(t, "Turn down", m.Actions[1].Label)
require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL)
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
}
func TestServer_PublishAsJSON(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4}`
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
`"delay":"30min"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
@@ -878,6 +913,57 @@ func TestServer_PublishAsJSON(t *testing.T) {
require.Equal(t, "google.pdf", m.Attachment.Name)
require.Equal(t, "http://ntfy.sh", m.Click)
require.Equal(t, 4, m.Priority)
require.True(t, m.Time > time.Now().Unix()+29*60)
require.True(t, m.Time < time.Now().Unix()+31*60)
}
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
mailer := &testMailer{}
s := newTestServer(t, newTestConfig(t))
s.mailer = mailer
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "A message", m.Message)
require.Equal(t, 1, mailer.Count())
}
func TestServer_PublishAsJSON_WithActions(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{
"topic":"mytopic",
"message":"A message",
"actions": [
{
"action": "view",
"label": "Open portal",
"url": "https://home.nest.com/"
},
{
"action": "http",
"label": "Turn down",
"url": "https://api.nest.com/device/XZ1D2",
"body": "target_temp_f=65"
}
]
}`
response := request(t, s, "POST", "/", body, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "A message", m.Message)
require.Equal(t, 2, len(m.Actions))
require.Equal(t, "view", m.Actions[0].Action)
require.Equal(t, "Open portal", m.Actions[0].Label)
require.Equal(t, "https://home.nest.com/", m.Actions[0].URL)
require.Equal(t, "http", m.Actions[1].Action)
require.Equal(t, "Turn down", m.Actions[1].Label)
require.Equal(t, "https://api.nest.com/device/XZ1D2", m.Actions[1].URL)
require.Equal(t, "target_temp_f=65", m.Actions[1].Body)
}
func TestServer_PublishAsJSON_Invalid(t *testing.T) {
@@ -907,7 +993,7 @@ func TestServer_PublishAttachment(t *testing.T) {
require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentsSize("9.9.9.9") // See request()
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
require.Nil(t, err)
require.Equal(t, int64(5000), size)
}
@@ -936,7 +1022,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentsSize("1.2.3.4")
size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(21), size)
}
@@ -956,7 +1042,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
require.Equal(t, "", msg.Attachment.Owner)
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
size, err := s.messageCache.AttachmentsSize("127.0.0.1")
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
require.Nil(t, err)
require.Equal(t, int64(0), size)
}
@@ -993,9 +1079,9 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
"Content-Length": "20000000",
})
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
@@ -1005,9 +1091,9 @@ func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.
s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
@@ -1037,9 +1123,9 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
content := util.RandomString(5001) // 5000+5001 > , see below
response = request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
@@ -1113,8 +1199,32 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
// And a failed one
response := request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 40012, err.Code)
require.Equal(t, 413, response.Code)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentUserStats(t *testing.T) {
content := util.RandomString(4999) // > 4096
c := newTestConfig(t)
c.AttachmentFileSizeLimit = 5000
c.VisitorAttachmentTotalSizeLimit = 6000
s := newTestServer(t, c)
// Upload one attachment
response := request(t, s, "PUT", "/mytopic", content, nil)
msg := toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
// User stats
response = request(t, s, "GET", "/user/stats", "", nil)
require.Equal(t, 200, response.Code)
var stats visitorStats
require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats))
require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit)
require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal)
require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed)
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
}
func newTestConfig(t *testing.T) *Config {

View File

@@ -27,6 +27,7 @@ type message struct {
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Actions []*action `json:"actions,omitempty"`
Attachment *attachment `json:"attachment,omitempty"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
@@ -42,6 +43,26 @@ type attachment struct {
Owner string `json:"-"` // IP address of uploader, used for rate limiting
}
type action struct {
ID string `json:"id"`
Action string `json:"action"` // "view", "broadcast", or "http"
Label string `json:"label"` // action button label
Clear bool `json:"clear"` // clear notification after successful execution
URL string `json:"url,omitempty"` // used in "view" and "http" actions
Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
Headers map[string]string `json:"headers,omitempty"` // used in "http" action
Body string `json:"body,omitempty"` // used in "http" action
Intent string `json:"intent,omitempty"` // used in "broadcast" action
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
}
func newAction() *action {
return &action{
Headers: make(map[string]string),
Extras: make(map[string]string),
}
}
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
Topic string `json:"topic"`
@@ -50,8 +71,11 @@ type publishMessage struct {
Priority int `json:"priority"`
Tags []string `json:"tags"`
Click string `json:"click"`
Actions []action `json:"actions"`
Attach string `json:"attach"`
Filename string `json:"filename"`
Email string `json:"email"`
Delay string `json:"delay"`
}
// messageEncoder is a function that knows how to encode a message

View File

@@ -22,6 +22,7 @@ var (
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
type visitor struct {
config *Config
messageCache *messageCache
ip string
requests *rate.Limiter
emails *rate.Limiter
@@ -31,9 +32,17 @@ type visitor struct {
mu sync.Mutex
}
func newVisitor(conf *Config, ip string) *visitor {
type visitorStats struct {
AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"`
VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"`
VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"`
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
}
func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
return &visitor{
config: conf,
messageCache: messageCache,
ip: ip,
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
@@ -91,3 +100,20 @@ func (v *visitor) Stale() bool {
defer v.mu.Unlock()
return time.Since(v.seen) > visitorExpungeAfter
}
func (v *visitor) Stats() (*visitorStats, error) {
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip)
if err != nil {
return nil, err
}
attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed
if attachmentsBytesRemaining < 0 {
attachmentsBytesRemaining = 0
}
return &visitorStats{
AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit,
VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit,
VisitorAttachmentBytesUsed: attachmentsBytesUsed,
VisitorAttachmentBytesRemaining: attachmentsBytesRemaining,
}, nil
}

View File

@@ -44,7 +44,7 @@ func TestSniffWriter_WriteUnknownMimeType(t *testing.T) {
rr := httptest.NewRecorder()
sw := NewContentTypeWriter(rr, "")
randomBytes := make([]byte, 199)
rand.Read(randomBytes)
rand.Read(randomBytes[5:]) // Start at an offset; the test kept failing randomly because it hit random magic strings
sw.Write(randomBytes)
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
}

View File

@@ -1,61 +0,0 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeakedReadCloser is a ReadCloser that allows peaking into a stream and buffering it in memory.
// It can be instantiated using the Peak function. After a stream has been peaked, it can still be fully
// read by reading the PeakedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeakedReadCloser struct {
PeakedBytes []byte
LimitReached bool
peaked io.Reader
underlying io.ReadCloser
closed bool
}
// Peak reads the underlying ReadCloser into memory up until the limit and returns a PeakedReadCloser
func Peak(underlying io.ReadCloser, limit int) (*PeakedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peaked := make([]byte, limit)
read, err := io.ReadFull(underlying, peaked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeakedReadCloser{
PeakedBytes: peaked[:read],
LimitReached: read == limit,
underlying: underlying,
peaked: bytes.NewReader(peaked[:read]),
closed: false,
}, nil
}
// Read reads from the peaked bytes and then from the underlying stream
func (r *PeakedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peaked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeakedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

61
util/peek.go Normal file
View File

@@ -0,0 +1,61 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeekedReadCloser is a ReadCloser that allows peeking into a stream and buffering it in memory.
// It can be instantiated using the Peek function. After a stream has been peeked, it can still be fully
// read by reading the PeekedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeekedReadCloser struct {
PeekedBytes []byte
LimitReached bool
peeked io.Reader
underlying io.ReadCloser
closed bool
}
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peeked := make([]byte, limit)
read, err := io.ReadFull(underlying, peeked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeekedReadCloser{
PeekedBytes: peeked[:read],
LimitReached: read == limit,
underlying: underlying,
peeked: bytes.NewReader(peeked[:read]),
closed: false,
}, nil
}
// Read reads from the peeked bytes and then from the underlying stream
func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peeked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeekedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

View File

@@ -9,11 +9,11 @@ import (
func TestPeak_LimitReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peak(underlying, 5)
peaked, err := Peek(underlying, 5)
if err != nil {
t.Fatal(err)
}
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, true, peaked.LimitReached)
all, err := io.ReadAll(peaked)
@@ -21,13 +21,13 @@ func TestPeak_LimitReached(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, true, peaked.LimitReached)
}
func TestPeak_LimitNotReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peak(underlying, 15)
peaked, err := Peek(underlying, 15)
if err != nil {
t.Fatal(err)
}
@@ -36,12 +36,12 @@ func TestPeak_LimitNotReached(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("1234567890"), peaked.PeakedBytes)
require.Equal(t, []byte("1234567890"), peaked.PeekedBytes)
require.Equal(t, false, peaked.LimitReached)
}
func TestPeak_Nil(t *testing.T) {
peaked, err := Peak(nil, 15)
peaked, err := Peek(nil, 15)
if err != nil {
t.Fatal(err)
}
@@ -50,6 +50,6 @@ func TestPeak_Nil(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte(""), all)
require.Equal(t, []byte(""), peaked.PeakedBytes)
require.Equal(t, []byte(""), peaked.PeekedBytes)
require.Equal(t, false, peaked.LimitReached)
}

View File

@@ -77,6 +77,16 @@ func SplitNoEmpty(s string, sep string) []string {
return res
}
// SplitKV splits a string into a key/value pair using a separator, and trimming space. If the separator
// is not found, key is empty.
func SplitKV(s string, sep string) (key string, value string) {
kv := strings.SplitN(strings.TrimSpace(s), sep, 2)
if len(kv) == 2 {
return strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
}
return "", strings.TrimSpace(kv[0])
}
// RandomString returns a random string with a given length
func RandomString(length int) string {
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!

View File

@@ -152,3 +152,17 @@ func TestParseSize_FailureInvalid(t *testing.T) {
t.Fatalf("expected error, but got none")
}
}
func TestSplitKV(t *testing.T) {
key, value := SplitKV(" key = value ", "=")
require.Equal(t, "key", key)
require.Equal(t, "value", value)
key, value = SplitKV(" value ", "=")
require.Equal(t, "", key)
require.Equal(t, "value", value)
key, value = SplitKV("mykey=value=with=separator ", "=")
require.Equal(t, "mykey", key)
require.Equal(t, "value=with=separator", value)
}

5322
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,15 +15,18 @@
"@mui/material": "latest",
"dexie": "^3.2.1",
"dexie-react-hooks": "^1.1.1",
"i18next": "^21.6.14",
"i18next-browser-languagedetector": "^6.1.4",
"i18next-http-backend": "^1.4.0",
"js-base64": "^3.7.2",
"react": "latest",
"react-dom": "latest",
"react-i18next": "^11.16.2",
"react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.2.2",
"react-scripts": "^5.0.0",
"stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2",
"svgo": "^2.8.0"
"stacktrace-js": "^2.0.2"
},
"browserslist": {
"production": [

View File

@@ -16,7 +16,7 @@ html {
}
a, a:visited {
color: #3a9784;
color: #338574;
}
a:hover {
@@ -114,7 +114,7 @@ code {
}
.anchor .anchorLink:hover {
color: #3a9784;
color: #338574;
visibility: visible;
}
@@ -221,7 +221,7 @@ figcaption {
/* Header */
#header {
background: #3a9784;
background: #338574;
height: 130px;
}

View File

@@ -0,0 +1,156 @@
{
"action_bar_clear_notifications": "Премахване на известия",
"alert_grant_description": "Разрешете на мрежовия четец да показва известия.",
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
"notifications_example": "Пример",
"notifications_no_subscriptions_title": "Липсват абонаменти",
"nav_topics_title": "Абонаменти",
"action_bar_send_test_notification": "Пробно известие",
"action_bar_unsubscribe": "Отписване",
"nav_button_all_notifications": "Всички известия",
"action_bar_settings": "Настройки",
"publish_dialog_title_topic": "Публикуване в темата {{topic}}",
"publish_dialog_title_no_topic": "Изпращане",
"publish_dialog_progress_uploading": "Изпращане…",
"publish_dialog_progress_uploading_detail": "Изпращане {{loaded}}/{{total}} ({{percent}}%)…",
"publish_dialog_message_published": "Известието е публикувано",
"publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението и квотата от {{fileSizeLimit}}, оставащи {{remainingBytes}}",
"publish_dialog_message_label": "Съобщение",
"publish_dialog_message_placeholder": "Въведете съобщение",
"publish_dialog_other_features": "Други възможности:",
"publish_dialog_chip_click_label": "Адрес",
"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_topic_label": "Промяна на темата",
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
"publish_dialog_button_cancel": "Отказ",
"subscribe_dialog_error_user_anonymous": "анонимен",
"prefs_notifications_title": "Известия",
"prefs_notifications_sound_title": "Звук при получаване",
"prefs_notifications_sound_no_sound": "Без звук",
"prefs_notifications_min_priority_title": "Минимален приоритет",
"prefs_notifications_min_priority_any": "Всички",
"prefs_notifications_min_priority_low_and_higher": "Нисък приоритет и по-висок",
"prefs_notifications_min_priority_default_and_higher": "Подразбиран приоритет и по-висок",
"prefs_notifications_min_priority_high_and_higher": "Висок приоритет и по-висок",
"prefs_notifications_min_priority_max_only": "Само максимален приоритет",
"prefs_notifications_delete_after_never": "Никога",
"prefs_users_add_button": "Добавяне",
"prefs_users_dialog_password_label": "Парола",
"alert_not_supported_description": "Мрежовият четец не поддържа известия.",
"message_bar_type_message": "Въведете съобщение",
"message_bar_error_publishing": "Грешка при изпращане на известието",
"notifications_copied_to_clipboard": "Копирано в междинната памет",
"notifications_attachment_link_expired": "препратката за изтегляне е невалидна",
"nav_button_settings": "Настройки",
"nav_button_documentation": "Ръководство",
"nav_button_subscribe": "Абониране за тема",
"alert_grant_title": "Известията са изключени",
"alert_grant_button": "Разрешаване",
"notifications_tags": "Етикети",
"nav_button_publish_message": "Изпращане",
"alert_not_supported_title": "Не се поддържат известия",
"notifications_attachment_open_title": "Към {{url}}",
"notifications_attachment_copy_url_button": "Копиране на адреса",
"notifications_attachment_open_button": "Отваряне на прикачения файл",
"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_any_description": "За да изпратите известия в тема, просто изпратете 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}}",
"publish_dialog_base_url_label": "Адрес на услугата",
"publish_dialog_base_url_placeholder": "Адрес на услугата, напр. https://example.com",
"publish_dialog_topic_placeholder": "Име на темата, напр. phils_alerts",
"publish_dialog_priority_low": "Нисък приоритет",
"publish_dialog_attachment_limits_quota_reached": "надвишава ограничението, оставащи {{remainingBytes}}",
"publish_dialog_priority_high": "Висок приоритет",
"publish_dialog_priority_default": "Подразбиран приоритет",
"publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска",
"publish_dialog_tags_label": "Етикети",
"publish_dialog_email_label": "Адрес на електронна поща",
"publish_dialog_priority_max": "Макс. приоритет",
"publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. внимание, диск",
"publish_dialog_click_label": "Адрес",
"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_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_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
"publish_dialog_button_send": "Изпращане",
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
"publish_dialog_attached_file_title": "Прикачен файл:",
"publish_dialog_attached_file_filename_placeholder": "Име на прикачения файл",
"publish_dialog_drop_file_here": "Пуснете файла тук",
"subscribe_dialog_subscribe_description": "Възможно е темите да не са защитени с парола, затова изберете име, което е трудно за отгатване. След като се абонирате, можете да изпращате известия по PUT или POST.",
"emoji_picker_search_placeholder": "Търсете емоция",
"subscribe_dialog_subscribe_title": "Абониране за тема",
"subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts",
"subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър",
"subscribe_dialog_login_username_label": "Потребител, напр. phil",
"subscribe_dialog_login_button_back": "Назад",
"subscribe_dialog_subscribe_button_cancel": "Отказ",
"subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.",
"subscribe_dialog_subscribe_button_subscribe": "Абониране",
"subscribe_dialog_login_title": "Изисква се вход",
"prefs_notifications_delete_after_title": "Автоматично премахване",
"prefs_notifications_delete_after_one_day": "След един ден",
"prefs_users_table_user_header": "Потребител",
"prefs_users_dialog_title_edit": "Промяна на потребител",
"prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh",
"prefs_users_dialog_button_cancel": "Отказ",
"prefs_users_dialog_button_save": "Запазване",
"prefs_appearance_language_title": "Език",
"subscribe_dialog_login_password_label": "Парола",
"subscribe_dialog_login_button_login": "Вход",
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
"prefs_appearance_title": "Външен вид",
"publish_dialog_delay_placeholder": "Забавяне на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
"prefs_notifications_delete_after_one_week": "След една седмица",
"prefs_users_title": "Управление на потребители",
"prefs_users_table_base_url_header": "Адрес на услугата",
"prefs_users_dialog_title_add": "Добавяне на потребител",
"prefs_notifications_delete_after_one_month": "След един месец",
"prefs_users_dialog_username_label": "Потребител, напр. phil",
"prefs_users_dialog_button_add": "Добавяне",
"error_boundary_title": "О, не, ntfy се срина",
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink>, или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Следа от стека",
"error_boundary_gathering_info": "Събиране на допълнителна информация…",
"notifications_loading": "Зареждане на известия…",
"error_boundary_button_copy_stack_trace": "Копиране на следата от стека",
"prefs_users_description": "Добавяйте и премахвайте потребители за защитените теми. Имайте предвид, че потребителското име и паролата се съхраняват в местната памет на мрежовия четец.",
"prefs_notifications_sound_description_none": "Известията не са съпроводени със звук",
"prefs_notifications_sound_description_some": "Известията са съпроводени със звука „{{sound}}“",
"prefs_notifications_delete_after_never_description": "Известията никога не се премахват автоматично",
"prefs_notifications_delete_after_three_hours_description": "Известията се премахват автоматично след три часа",
"priority_min": "минимален",
"priority_low": "нисък",
"priority_high": "висок",
"priority_max": "максимален",
"priority_default": "подразбиран",
"prefs_notifications_delete_after_one_week_description": "Известията се премахват автоматично след една седмица",
"prefs_notifications_delete_after_one_day_description": "Известията се премахват автоматично след един ден",
"prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)",
"prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец",
"prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета им",
"prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок",
"notifications_actions_http_request_title": "Изпращане на HTTP {{method}} до {{url}}",
"notifications_actions_not_supported": "Действието не се поддържа от приложението за уеб"
}

View File

@@ -0,0 +1,156 @@
{
"action_bar_settings": "Nastavení",
"action_bar_send_test_notification": "Odeslání testovacího oznámení",
"action_bar_clear_notifications": "Vymazat všechna oznámení",
"action_bar_unsubscribe": "Odhlásit odběr",
"message_bar_type_message": "Zde napište zprávu",
"message_bar_error_publishing": "Chyba při odesílání oznámení",
"nav_topics_title": "Odebíraná témata",
"nav_button_all_notifications": "Všechna oznámení",
"nav_button_settings": "Nastavení",
"nav_button_documentation": "Dokumentace",
"nav_button_publish_message": "Odeslat oznámení",
"nav_button_subscribe": "Přihlásit se k odběru tématu",
"alert_grant_title": "Oznámení jsou zakázána",
"alert_grant_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
"alert_grant_button": "Udělit nyní",
"alert_not_supported_title": "Oznámení nejsou podporována",
"alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována.",
"notifications_copied_to_clipboard": "Zkopírováno do schránky",
"notifications_tags": "Značky",
"notifications_attachment_copy_url_title": "Kopírovat URL přílohy do schránky",
"notifications_attachment_copy_url_button": "Kopírovat URL",
"notifications_attachment_open_title": "Přejít na {{url}}",
"notifications_attachment_open_button": "Otevřít přílohu",
"notifications_attachment_link_expires": "platnost odkazu končí {{date}}",
"notifications_attachment_link_expired": "platnost odkazu ke stažení vypršela",
"notifications_click_copy_url_title": "Kopírovat URL odkazu do schránky",
"notifications_click_copy_url_button": "Kopírovat odkaz",
"notifications_click_open_button": "Otevřít odkaz",
"notifications_none_for_topic_title": "K tomuto tématu jste zatím neobdrželi žádné oznámení.",
"notifications_none_for_topic_description": "Pro odeslání oznámení k tomuto tématu, odešlete PUT nebo POST požadavek na URL tématu.",
"notifications_example": "Příklad",
"publish_dialog_base_url_placeholder": "URL služby, např. https://example.com",
"publish_dialog_topic_label": "Název tématu",
"publish_dialog_topic_placeholder": "Název tématu, např. phil_alerts",
"publish_dialog_priority_default": "Výchozí priorita",
"publish_dialog_priority_high": "Vysoká priorita",
"publish_dialog_priority_max": "Nevyšší priorita",
"publish_dialog_base_url_label": "URL služby",
"prefs_users_dialog_password_label": "Heslo",
"prefs_users_dialog_title_add": "Přidat uživatele",
"prefs_users_dialog_title_edit": "Upravit uživatele",
"prefs_users_dialog_base_url_label": "URL služby, např. https://ntfy.sh",
"prefs_users_dialog_username_label": "Uživatelské jméno, např. phil",
"notifications_actions_open_url_title": "Přejít na {{url}}",
"notifications_none_for_any_title": "Neobdrželi jste žádná oznámení.",
"notifications_none_for_any_description": "Pro odeslání oznámení k tématu stačí na URL tématu odeslat PUT nebo POST požadavek. Zde je příklad s použitím jednoho z vašich témat.",
"notifications_no_subscriptions_description": "Kliknutím na \"{{linktext}}\" vytvoříte téma nebo se k němu přihlásíte. Poté můžete odesílat zprávy prostřednictvím PUT nebo POST požadavků a zde budete dostávat oznámení.",
"notifications_more_details": "Další informace naleznete na <websiteLink>webových stránkách</websiteLink> nebo v <docsLink>dokumentaci</docsLink>.",
"publish_dialog_title_topic": "Odeslat do {{téma}}",
"publish_dialog_title_no_topic": "Odeslat oznámení",
"publish_dialog_progress_uploading": "Nahrávání …",
"publish_dialog_message_published": "Oznámení odesláno",
"publish_dialog_attachment_limits_file_and_quota_reached": "překračuje {{fileSizeLimit}} limit souboru a kvótu, {{remainingBytes}} zbývá",
"publish_dialog_attachment_limits_quota_reached": "překračuje kvótu, {{remainingBytes}} zbývá",
"publish_dialog_priority_min": "Nejnižší priorita",
"publish_dialog_title_label": "Název",
"publish_dialog_title_placeholder": "Název oznámení, např. Upozornění na volné místo na disku",
"publish_dialog_message_placeholder": "Zde napište zprávu",
"publish_dialog_tags_label": "Značky",
"publish_dialog_priority_label": "Priorita",
"publish_dialog_click_label": "Klikněte na URL",
"publish_dialog_click_placeholder": "Adresa URL, která se otevře po kliknutí na oznámení",
"publish_dialog_email_label": "E-mail",
"publish_dialog_email_placeholder": "Adresa pro odeslání oznámení, např. phil@example.com",
"publish_dialog_attach_label": "URL přílohy",
"publish_dialog_filename_label": "Název souboru",
"publish_dialog_filename_placeholder": "Název souboru přílohy",
"publish_dialog_delay_label": "Zpoždění",
"publish_dialog_delay_placeholder": "Zpožděné doručení, např. {{unixTimestamp}}, {{relativeTime}} nebo \"{{naturalLanguage}}\". (pouze v angličtině)",
"publish_dialog_chip_click_label": "Klikněte na URL",
"publish_dialog_chip_email_label": "Přeposlat na e-mail",
"publish_dialog_chip_delay_label": "Zpožděné doručení",
"publish_dialog_chip_topic_label": "Změnit téma",
"publish_dialog_details_examples_description": "Příklady a podrobný popis všech funkcí odesílání naleznete v <docsLink>dokumentaci</docsLink>.",
"publish_dialog_chip_attach_url_label": "Připojit soubor pomocí URL",
"publish_dialog_chip_attach_file_label": "Připojit místní soubor",
"publish_dialog_button_send": "Odeslat",
"publish_dialog_checkbox_publish_another": "Odeslat další",
"publish_dialog_attached_file_title": "Přiložený soubor:",
"publish_dialog_attached_file_filename_placeholder": "Název souboru přílohy",
"publish_dialog_drop_file_here": "Přetáhněte soubor sem",
"emoji_picker_search_placeholder": "Hledat emodži",
"subscribe_dialog_subscribe_title": "Přihlásit odběr tématu",
"subscribe_dialog_subscribe_description": "Témata nemusí být chráněna heslem, proto zvolte název, který není snadné uhodnout. Jakmile se přihlásíte k odběru, můžete odesílat oznámení pomocí PUT/POST požadavků.",
"subscribe_dialog_subscribe_topic_placeholder": "Název tématu, např. phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Použít jiný server",
"subscribe_dialog_login_title": "Vyžadováno přihlášení",
"subscribe_dialog_login_description": "Toto téma je chráněno heslem. Pro přihlášení k odběru zadejte prosím uživatelské jméno a heslo.",
"subscribe_dialog_subscribe_button_cancel": "Zrušit",
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
"subscribe_dialog_login_password_label": "Heslo",
"subscribe_dialog_login_button_back": "Zpět",
"subscribe_dialog_login_button_login": "Přihlásit se",
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
"subscribe_dialog_error_user_anonymous": "anonymně",
"prefs_notifications_title": "Oznámení",
"prefs_notifications_sound_description_some": "Oznámení přehrají při doručení zvuk {{sound}}",
"prefs_notifications_min_priority_description_x_or_higher": "Zobrazit oznámení, pokud je priorita {{number}} ({{name}}) nebo vyšší",
"prefs_notifications_min_priority_description_max": "Zobrazit oznámení, pokud je priorita 5 (nejvyšší)",
"prefs_notifications_min_priority_any": "Jakákoli priorita",
"prefs_notifications_min_priority_low_and_higher": "Nízká priorita a vyšší",
"prefs_notifications_min_priority_default_and_higher": "Výchozí priorita a vyšší",
"prefs_notifications_min_priority_high_and_higher": "Vysoká priorita a vyšší",
"prefs_notifications_delete_after_three_hours": "Po třech hodinách",
"prefs_notifications_delete_after_one_day": "Po jednom dni",
"prefs_notifications_delete_after_one_week": "Po jednom týdnu",
"prefs_notifications_delete_after_one_month": "Po jednom měsíci",
"prefs_notifications_delete_after_never_description": "Oznámení nejsou nikdy automaticky mazána",
"prefs_notifications_delete_after_three_hours_description": "Oznámení se automaticky odstraní po třech hodinách",
"prefs_notifications_delete_after_one_day_description": "Oznámení se automaticky odstraní po jednom dni",
"prefs_notifications_delete_after_one_week_description": "Oznámení se automaticky odstraní po jednom týdnu",
"prefs_notifications_delete_after_one_month_description": "Oznámení se automaticky odstraní po jednom měsíci",
"prefs_users_title": "Správa uživatelů",
"prefs_users_add_button": "Přidat uživatele",
"prefs_users_table_user_header": "Uživatel",
"prefs_users_table_base_url_header": "URL služby",
"prefs_users_dialog_button_cancel": "Zrušit",
"prefs_users_dialog_button_add": "Přidat",
"prefs_users_dialog_button_save": "Uložit",
"priority_min": "nejnižší",
"priority_low": "nízká",
"priority_default": "výchozí",
"priority_high": "vysoká",
"priority_max": "nejvyšší",
"error_boundary_title": "Ale ne, ntfy havaroval",
"error_boundary_description": "K tomu by samozřejmě nemělo docházet. Velmi se za to omlouváme.<br/>Pokud máte chvilku, nahlaste to prosím <githubLink>na GitHubu</githubLink> nebo nám dejte vědět prostřednictvím <discordLink>Discordu</discordLink> nebo <matrixLink>Matrixu</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Kopírovat výpis zásobníku",
"error_boundary_stack_trace": "Výpis zásobníku",
"publish_dialog_tags_placeholder": "Seznam značek oddělených čárkou, např. warning, srv1-backup",
"notifications_actions_not_supported": "Akce není podporována ve webové aplikaci",
"notifications_actions_http_request_title": "Odeslat HTTP {{metoda}} na {{url}}",
"notifications_no_subscriptions_title": "Vypadá to, že ještě nemáte žádné odběry.",
"prefs_notifications_min_priority_description_any": "Zobrazit všechna oznámení bez ohledu na prioritu",
"publish_dialog_priority_low": "Nízká priorita",
"publish_dialog_message_label": "Zpráva",
"publish_dialog_button_cancel": "Zrušit",
"notifications_loading": "Načítání oznámení …",
"publish_dialog_progress_uploading_detail": "Nahrávání {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_attachment_limits_file_reached": "překračuje {{fileSizeLimit}} limit souboru",
"publish_dialog_attach_placeholder": "Připojit soubor pomocí URL, např. https://f-droid.org/F-Droid.apk",
"publish_dialog_button_cancel_sending": "Zrušit odesílání",
"publish_dialog_other_features": "Další funkce:",
"prefs_notifications_sound_title": "Zvuk oznámení",
"prefs_notifications_min_priority_max_only": "Pouze nejvyšší priorita",
"prefs_notifications_min_priority_title": "Nejnižší priorita",
"prefs_notifications_delete_after_title": "Odstranit oznámení",
"prefs_notifications_delete_after_never": "Nikdy",
"prefs_notifications_sound_no_sound": "Žádný zvuk",
"prefs_notifications_sound_description_none": "Oznámení nepřehrají při doručení žádný zvuk",
"prefs_users_description": "Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.",
"error_boundary_gathering_info": "Získejte více informací …",
"prefs_appearance_language_title": "Jazyk",
"prefs_appearance_title": "Vzhled"
}

View File

@@ -0,0 +1,156 @@
{
"nav_topics_title": "Abonnierte Themen",
"nav_button_all_notifications": "Alle Benachrichtigungen",
"nav_button_settings": "Einstellungen",
"nav_button_documentation": "Dokumentation",
"nav_button_publish_message": "Benachrichtigung senden",
"nav_button_subscribe": "Thema abonnieren",
"alert_grant_title": "Benachrichtigungen sind deaktiviert",
"publish_dialog_base_url_label": "Service-URL",
"publish_dialog_details_examples_description": "Beispiele und ausführliche Informationen zu allen Optionen findest Du in der <docsLink>Dokumentation</docsLink>.",
"publish_dialog_attached_file_filename_placeholder": "Dateiname des Anhangs",
"subscribe_dialog_login_description": "Dieses Thema benötigt eine Anmeldung. Bitte gib Benutzernamen und Kennwort ein.",
"prefs_notifications_title": "Benachrichtigungen",
"prefs_notifications_sound_title": "Benachrichtigungston",
"prefs_notifications_min_priority_max_only": "Nur höchste Priorität",
"prefs_notifications_delete_after_never": "Nie",
"prefs_users_dialog_password_label": "Kennwort",
"prefs_users_dialog_button_cancel": "Abbrechen",
"prefs_users_dialog_button_add": "Hinzufügen",
"prefs_users_dialog_button_save": "Speichern",
"prefs_appearance_language_title": "Sprache",
"notifications_none_for_any_description": "Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.",
"publish_dialog_message_placeholder": "Gib hier eine Nachricht ein",
"notifications_attachment_link_expires": "Link läuft ab am/um {{date}}",
"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": "Von Thema abmelden",
"notifications_copied_to_clipboard": "In Zwischenablage kopiert",
"notifications_loading": "Benachrichtigungen werden geladen …",
"notifications_attachment_open_title": "Gehe zu {{url}}",
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
"alert_grant_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
"notifications_tags": "Tags",
"message_bar_type_message": "Gib hier eine Nachricht ein",
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
"alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt.",
"action_bar_settings": "Einstellungen",
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
"alert_grant_button": "Jetzt erlauben",
"notifications_none_for_topic_title": "Du hast für dieses Thema noch keine Benachrichtigungen empfangen.",
"notifications_click_open_button": "Link öffnen",
"notifications_more_details": "Ausführlichere Informationen findest Du auf der <websiteLink>Website</websiteLink> und in der <docsLink>Dokumentation</docsLink>.",
"notifications_attachment_copy_url_title": "URL des Anhangs in Zwischenablage kopieren",
"notifications_attachment_copy_url_button": "URL kopieren",
"notifications_attachment_open_button": "Anhang öffnen",
"notifications_attachment_link_expired": "Download-Link ist abgelaufen",
"notifications_click_copy_url_button": "Link kopieren",
"notifications_actions_open_url_title": "Gehe zu {{url}}",
"publish_dialog_other_features": "Andere Optionen:",
"notifications_none_for_topic_description": "Um Benachrichtigungen an dieses Thema zu senden, PUTe/POSTe an die Themen-URL.",
"notifications_no_subscriptions_title": "Anscheinend hast Du noch keine Themen abonniert.",
"notifications_no_subscriptions_description": "Klicke den „{{linktext}}“-Link um ein Thema zu erstellen oder zu abonnieren. Danach kannst Du Nachrichten per PUT oder POST senden und erhältst hier die Benachrichtigungen.",
"notifications_example": "Beispiel",
"publish_dialog_progress_uploading": "Wird hochgeladen …",
"publish_dialog_title_topic": "Senden an {{topic}}",
"publish_dialog_title_no_topic": "Benachrichtigung senden",
"publish_dialog_message_published": "Benachrichtigung gesendet",
"publish_dialog_attachment_limits_file_and_quota_reached": "überschreitet das Dateigrößen-Limit {{fileSizeLimit}} und die Quota, {{remainingBytes}} übrig",
"publish_dialog_progress_uploading_detail": "Hochladen {{loaded}}/{{total}} ({{percent}} %) …",
"publish_dialog_priority_max": "Max. Priorität",
"publish_dialog_topic_placeholder": "Thema, z.B. phil_alerts",
"publish_dialog_attachment_limits_file_reached": "überschreitet das Dateigrößen-Limit {{filesizeLimit}}",
"publish_dialog_topic_label": "Thema",
"publish_dialog_priority_default": "Standard-Priorität",
"publish_dialog_base_url_placeholder": "Service-URL, z.B. https://example.com",
"publish_dialog_attachment_limits_quota_reached": "überschreitet die Quota, {{remainingBytes}} übrig",
"publish_dialog_priority_min": "Min. Priorität",
"publish_dialog_priority_high": "Hohe Priorität",
"publish_dialog_title_label": "Titel",
"publish_dialog_tags_placeholder": "Komma-getrennte Liste von Tags, z.B. Warnung, srv1-Backup",
"publish_dialog_priority_label": "Priorität",
"publish_dialog_filename_label": "Dateiname",
"publish_dialog_title_placeholder": "Benachrichtigungs-Titel, z.B. CPU-Last-Warnung",
"publish_dialog_tags_label": "Tags",
"publish_dialog_click_label": "Klick-URL",
"publish_dialog_click_placeholder": "URL die geöffnet werden soll, wenn die Benachrichtigung angeklickt wird",
"publish_dialog_email_label": "E-Mail",
"publish_dialog_attach_label": "URL des Anhangs",
"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": "Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@beispiel.com",
"publish_dialog_chip_click_label": "Klick-URL",
"publish_dialog_button_cancel_sending": "Senden abbrechen",
"publish_dialog_drop_file_here": "Datei hierher ziehen",
"publish_dialog_chip_email_label": "An E-Mail weiterleiten",
"publish_dialog_button_cancel": "Abbrechen",
"publish_dialog_chip_attach_file_label": "Lokale Datei anhängen",
"prefs_notifications_min_priority_title": "Minimale Priorität",
"prefs_users_add_button": "Benutzer hinzufügen",
"publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)",
"prefs_appearance_title": "Darstellung",
"subscribe_dialog_login_password_label": "Kennwort",
"subscribe_dialog_login_button_back": "Zurück",
"publish_dialog_chip_attach_url_label": "Datei von URL anhängen",
"publish_dialog_chip_delay_label": "Auslieferung verzögern",
"publish_dialog_chip_topic_label": "Thema ändern",
"subscribe_dialog_subscribe_title": "Thema abonnieren",
"subscribe_dialog_login_username_label": "Benutzername, z.B. phil",
"subscribe_dialog_login_button_login": "Anmelden",
"prefs_notifications_sound_no_sound": "Kein Ton",
"prefs_notifications_min_priority_default_and_higher": "Standard-Priorität und höher",
"subscribe_dialog_subscribe_topic_placeholder": "Thema, z.B. phil_alerts",
"publish_dialog_button_send": "Senden",
"publish_dialog_checkbox_publish_another": "Weitere Nachricht senden",
"publish_dialog_attached_file_title": "Dateianhang:",
"emoji_picker_search_placeholder": "Emoji suchen",
"subscribe_dialog_subscribe_description": "Themen sind evtl. nicht kennwort-geschützt, also wähle einen schwer zu erratenden Namen. Nach dem Abonnieren kannst Du Benachrichtigungen per POST/PUT senden.",
"subscribe_dialog_subscribe_use_another_label": "Anderen Server verwenden",
"subscribe_dialog_subscribe_button_cancel": "Abbrechen",
"subscribe_dialog_subscribe_button_subscribe": "Abonnieren",
"subscribe_dialog_login_title": "Anmeldung erforderlich",
"subscribe_dialog_error_user_anonymous": "anonym",
"subscribe_dialog_error_user_not_authorized": "Benutzer {{username}} hat keine Berechtigung",
"prefs_notifications_min_priority_any": "Alle Prioritäten",
"prefs_notifications_min_priority_low_and_higher": "Niedrige Priorität und höher",
"prefs_notifications_min_priority_high_and_higher": "Hohe Priorität und höher",
"prefs_notifications_delete_after_title": "Benachrichtigungen löschen",
"prefs_notifications_delete_after_three_hours": "Nach drei Stunden",
"prefs_users_dialog_title_edit": "Benutzer bearbeiten",
"prefs_notifications_delete_after_one_day": "Nach einem Tag",
"prefs_notifications_delete_after_one_week": "Nach einer Woche",
"prefs_notifications_delete_after_one_month": "Nach einem Monat",
"prefs_users_title": "Benutzer verwalten",
"prefs_users_table_user_header": "Benutzer",
"prefs_users_table_base_url_header": "Service-URL",
"prefs_users_dialog_base_url_label": "Service-URL, z.B. https://ntfy.sh",
"prefs_users_dialog_username_label": "Benutzername, z.B. phil",
"prefs_users_description": "Benutzer für kennwort-geschützte Themen hinzufügen/löschen. Achtung: Benutzername und Kennwort werden im lokalen Browser-Speicher abgelegt.",
"prefs_users_dialog_title_add": "Benutzer hinzufügen",
"error_boundary_title": "Oh nein, ntfy ist abgestürzt",
"error_boundary_description": "Das sollte offensichtlich nicht passieren. Sorry.<br/>Wenn möglich, <githubLink>melde den Fehler auf GitHub</githubLink> oder schreibe uns auf <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Stacktrace",
"error_boundary_gathering_info": "Weitere Informationen sammeln …",
"error_boundary_button_copy_stack_trace": "Stacktrace kopieren",
"prefs_notifications_delete_after_never_description": "Benachrichtigungen werden nie automatisch gelöscht",
"prefs_notifications_delete_after_one_month_description": "Benachrichtigungen werden nach einem Monat automatisch gelöscht",
"prefs_notifications_min_priority_description_any": "Alle Benachrichtigungen (aller Prioritäten) anzeigen",
"prefs_notifications_min_priority_description_max": "Zeige Benachrichtigungen wenn ihre Priorität5 (max) ist",
"priority_low": "niedrig",
"priority_default": "Standard",
"priority_high": "hoch",
"priority_max": "max",
"prefs_notifications_sound_description_none": "Kein Ton beim Empfang einer Benachrichtigung",
"prefs_notifications_sound_description_some": "Sound {{sound}} beim Eintreffen einer Benachrichtigung abspielen",
"prefs_notifications_min_priority_description_x_or_higher": "Zeige Benachrichtigungen wenn ihre Priorität {{number}} ({{name}}) oder höher ist",
"prefs_notifications_delete_after_three_hours_description": "Benachrichtigungen werden nach drei Stunden automatisch gelöscht",
"prefs_notifications_delete_after_one_day_description": "Benachrichtigungen werden nach einem Tag automatisch gelöscht",
"prefs_notifications_delete_after_one_week_description": "Benachrichtigungen werden nach einer Woche automatisch gelöscht",
"priority_min": "min",
"notifications_actions_not_supported": "Diese Aktion wird in der Web-App nicht unterstützt",
"notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}"
}

View File

@@ -0,0 +1,191 @@
{
"action_bar_show_menu": "Show menu",
"action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Settings",
"action_bar_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications",
"action_bar_unsubscribe": "Unsubscribe",
"action_bar_toggle_mute": "Mute/unmute notifications",
"action_bar_toggle_action_menu": "Open/close action menu",
"message_bar_type_message": "Type a message here",
"message_bar_error_publishing": "Error publishing notification",
"message_bar_show_dialog": "Show publish dialog",
"message_bar_publish": "Publish message",
"nav_topics_title": "Subscribed topics",
"nav_button_all_notifications": "All notifications",
"nav_button_settings": "Settings",
"nav_button_documentation": "Documentation",
"nav_button_publish_message": "Publish notification",
"nav_button_subscribe": "Subscribe to topic",
"nav_button_muted": "Notifications muted",
"nav_button_connecting": "connecting",
"alert_grant_title": "Notifications are disabled",
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
"alert_grant_button": "Grant now",
"alert_not_supported_title": "Notifications not supported",
"alert_not_supported_description": "Notifications are not supported in your browser.",
"notifications_list": "Notifications list",
"notifications_list_item": "Notification",
"notifications_mark_read": "Mark as read",
"notifications_delete": "Delete",
"notifications_copied_to_clipboard": "Copied to clipboard",
"notifications_tags": "Tags",
"notifications_priority_x": "Priority {{priority}}",
"notifications_new_indicator": "New notification",
"notifications_attachment_image": "Attachment image",
"notifications_attachment_copy_url_title": "Copy attachment URL to clipboard",
"notifications_attachment_copy_url_button": "Copy URL",
"notifications_attachment_open_title": "Go to {{url}}",
"notifications_attachment_open_button": "Open attachment",
"notifications_attachment_link_expires": "link expires {{date}}",
"notifications_attachment_link_expired": "download link expired",
"notifications_attachment_file_image": "image file",
"notifications_attachment_file_video": "video file",
"notifications_attachment_file_audio": "audio file",
"notifications_attachment_file_app": "Android app file",
"notifications_attachment_file_document": "other document",
"notifications_click_copy_url_title": "Copy link URL to clipboard",
"notifications_click_copy_url_button": "Copy link",
"notifications_click_open_button": "Open link",
"notifications_actions_open_url_title": "Go to {{url}}",
"notifications_actions_not_supported": "Action not supported in web app",
"notifications_actions_http_request_title": "Send HTTP {{method}} to {{url}}",
"notifications_none_for_topic_title": "You haven't received any notifications for this topic yet.",
"notifications_none_for_topic_description": "To send notifications to this topic, simply PUT or POST to the topic URL.",
"notifications_none_for_any_title": "You haven't received any notifications.",
"notifications_none_for_any_description": "To send notifications to a topic, simply PUT or POST to the topic URL. Here's an example using one of your topics.",
"notifications_no_subscriptions_title": "It looks like you don't have any subscriptions yet.",
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
"notifications_example": "Example",
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
"notifications_loading": "Loading notifications …",
"publish_dialog_title_topic": "Publish to {{topic}}",
"publish_dialog_title_no_topic": "Publish notification",
"publish_dialog_progress_uploading": "Uploading …",
"publish_dialog_progress_uploading_detail": "Uploading {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Notification published",
"publish_dialog_attachment_limits_file_and_quota_reached": "exceeds {{fileSizeLimit}} file limit and quota, {{remainingBytes}} remaining",
"publish_dialog_attachment_limits_file_reached": "exceeds {{fileSizeLimit}} file limit",
"publish_dialog_attachment_limits_quota_reached": "exceeds quota, {{remainingBytes}} remaining",
"publish_dialog_emoji_picker_show": "Pick emoji",
"publish_dialog_priority_min": "Min. priority",
"publish_dialog_priority_low": "Low priority",
"publish_dialog_priority_default": "Default priority",
"publish_dialog_priority_high": "High priority",
"publish_dialog_priority_max": "Max. priority",
"publish_dialog_base_url_label": "Service URL",
"publish_dialog_base_url_placeholder": "Service URL, e.g. https://example.com",
"publish_dialog_topic_label": "Topic name",
"publish_dialog_topic_placeholder": "Topic name, e.g. phil_alerts",
"publish_dialog_topic_reset": "Reset topic",
"publish_dialog_title_label": "Title",
"publish_dialog_title_placeholder": "Notification title, e.g. Disk space alert",
"publish_dialog_message_label": "Message",
"publish_dialog_message_placeholder": "Type a message here",
"publish_dialog_tags_label": "Tags",
"publish_dialog_tags_placeholder": "Comma-separated list of tags, e.g. warning, srv1-backup",
"publish_dialog_priority_label": "Priority",
"publish_dialog_click_label": "Click URL",
"publish_dialog_click_placeholder": "URL that is opened when notification is clicked",
"publish_dialog_click_reset": "Remove click URL",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
"publish_dialog_email_reset": "Remove email forward",
"publish_dialog_attach_label": "Attachment URL",
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "Remove attachment URL",
"publish_dialog_filename_label": "Filename",
"publish_dialog_filename_placeholder": "Attachment filename",
"publish_dialog_delay_label": "Delay",
"publish_dialog_delay_placeholder": "Delay delivery, e.g. {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
"publish_dialog_delay_reset": "Remove delayed delivery",
"publish_dialog_other_features": "Other features:",
"publish_dialog_chip_click_label": "Click URL",
"publish_dialog_chip_email_label": "Forward to email",
"publish_dialog_chip_attach_url_label": "Attach file by URL",
"publish_dialog_chip_attach_file_label": "Attach local file",
"publish_dialog_chip_delay_label": "Delay delivery",
"publish_dialog_chip_topic_label": "Change topic",
"publish_dialog_details_examples_description": "For examples and a detailed description of all send features, please refer to the <docsLink>documentation</docsLink>.",
"publish_dialog_button_cancel_sending": "Cancel sending",
"publish_dialog_button_cancel": "Cancel",
"publish_dialog_button_send": "Send",
"publish_dialog_checkbox_publish_another": "Publish another",
"publish_dialog_attached_file_title": "Attached file:",
"publish_dialog_attached_file_filename_placeholder": "Attachment filename",
"publish_dialog_attached_file_remove": "Remove attached file",
"publish_dialog_drop_file_here": "Drop file here",
"emoji_picker_search_placeholder": "Search emoji",
"emoji_picker_search_clear": "Clear search",
"subscribe_dialog_subscribe_title": "Subscribe to topic",
"subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.",
"subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Use another server",
"subscribe_dialog_subscribe_base_url_label": "Service URL",
"subscribe_dialog_subscribe_button_cancel": "Cancel",
"subscribe_dialog_subscribe_button_subscribe": "Subscribe",
"subscribe_dialog_login_title": "Login required",
"subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.",
"subscribe_dialog_login_username_label": "Username, e.g. phil",
"subscribe_dialog_login_password_label": "Password",
"subscribe_dialog_login_button_back": "Back",
"subscribe_dialog_login_button_login": "Login",
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
"subscribe_dialog_error_user_anonymous": "anonymous",
"prefs_notifications_title": "Notifications",
"prefs_notifications_sound_title": "Notification sound",
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
"prefs_notifications_sound_description_some": "Notifications play the {{sound}} sound when they arrive",
"prefs_notifications_sound_no_sound": "No sound",
"prefs_notifications_sound_play": "Play selected sound",
"prefs_notifications_min_priority_title": "Minimum priority",
"prefs_notifications_min_priority_description_any": "Showing all notifications, regardless of priority",
"prefs_notifications_min_priority_description_x_or_higher": "Show notifications if priority is {{number}} ({{name}}) or above",
"prefs_notifications_min_priority_description_max": "Show notifications if priority is 5 (max)",
"prefs_notifications_min_priority_any": "Any priority",
"prefs_notifications_min_priority_low_and_higher": "Low priority and higher",
"prefs_notifications_min_priority_default_and_higher": "Default priority and higher",
"prefs_notifications_min_priority_high_and_higher": "High priority and higher",
"prefs_notifications_min_priority_max_only": "Only max priority",
"prefs_notifications_delete_after_title": "Delete notifications",
"prefs_notifications_delete_after_never": "Never",
"prefs_notifications_delete_after_three_hours": "After three hours",
"prefs_notifications_delete_after_one_day": "After one day",
"prefs_notifications_delete_after_one_week": "After one week",
"prefs_notifications_delete_after_one_month": "After one month",
"prefs_notifications_delete_after_never_description": "Notifications are never auto-deleted",
"prefs_notifications_delete_after_three_hours_description": "Notifications are auto-deleted after three hours",
"prefs_notifications_delete_after_one_day_description": "Notifications are auto-deleted after one day",
"prefs_notifications_delete_after_one_week_description": "Notifications are auto-deleted after one week",
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
"prefs_users_title": "Manage users",
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
"prefs_users_table": "Users table",
"prefs_users_add_button": "Add user",
"prefs_users_edit_button": "Edit user",
"prefs_users_delete_button": "Delete user",
"prefs_users_table_user_header": "User",
"prefs_users_table_base_url_header": "Service URL",
"prefs_users_dialog_title_add": "Add user",
"prefs_users_dialog_title_edit": "Edit user",
"prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh",
"prefs_users_dialog_username_label": "Username, e.g. phil",
"prefs_users_dialog_password_label": "Password",
"prefs_users_dialog_button_cancel": "Cancel",
"prefs_users_dialog_button_add": "Add",
"prefs_users_dialog_button_save": "Save",
"prefs_appearance_title": "Appearance",
"prefs_appearance_language_title": "Language",
"priority_min": "min",
"priority_low": "low",
"priority_default": "default",
"priority_high": "high",
"priority_max": "max",
"error_boundary_title": "Oh no, ntfy crashed",
"error_boundary_description": "This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Copy stack trace",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_gathering_info": "Gather more info …",
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>."
}

View File

@@ -0,0 +1,156 @@
{
"action_bar_settings": "Configuración",
"action_bar_send_test_notification": "Enviar notificación de prueba",
"action_bar_clear_notifications": "Borrar todas las notificaciones",
"nav_topics_title": "Tópicos suscritos",
"alert_grant_button": "Conceder ahora",
"action_bar_unsubscribe": "Cancelar la suscripción",
"message_bar_type_message": "Escriba un mensaje aquí",
"message_bar_error_publishing": "Error al publicar la notificación",
"alert_grant_title": "Las notificaciones están deshabilitadas",
"alert_grant_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.",
"nav_button_all_notifications": "Todas las notificaciones",
"nav_button_settings": "Ajustes",
"nav_button_subscribe": "Suscribirse al tópico",
"nav_button_documentation": "Documentación",
"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.",
"notifications_tags": "Etiquetas",
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
"notifications_attachment_copy_url_button": "Copiar URL",
"notifications_attachment_open_title": "Ir a {{url}}",
"notifications_attachment_open_button": "Abrir archivo adjunto",
"notifications_attachment_link_expires": "el enlace expira el día {{fecha}}",
"notifications_attachment_link_expired": "el enlace de descarga ha expirado",
"notifications_click_copy_url_title": "Copiar la URL del enlace en el portapapeles",
"notifications_click_copy_url_button": "Copiar enlace",
"notifications_actions_open_url_title": "Ir a {{url}}",
"notifications_click_open_button": "Abrir enlace",
"notifications_none_for_topic_title": "Aún no has recibido ninguna notificación en este tópico.",
"notifications_none_for_topic_description": "Para enviar notificaciones a este tópico, simplemente realice un PUT o POST a la URL del tópico.",
"notifications_none_for_any_title": "No ha recibido ninguna notificación.",
"notifications_no_subscriptions_title": "Parece que aún no tiene ninguna suscripción.",
"notifications_no_subscriptions_description": "Haga clic en el enlace \"{{linktext}}\" para crear o suscribirse a un tópico. Después, puede enviar mensajes a través de un PUT o POST y recibirá notificaciones aquí.",
"notifications_more_details": "Para más información, consulta la <websiteLink>página web</websiteLink> o la <docsLink>documentación</docsLink>.",
"notifications_loading": "Cargando notificaciones …",
"publish_dialog_title_topic": "Publicar en {{topic}}",
"publish_dialog_title_no_topic": "Publicar notificación",
"publish_dialog_progress_uploading": "Cargando …",
"publish_dialog_progress_uploading_detail": "Cargando {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Notificación publicada",
"publish_dialog_attachment_limits_file_and_quota_reached": "supera el límite y la cuota de archivos de {{fileSizeLimit}}, restan {{remainingBytes}}",
"publish_dialog_attachment_limits_file_reached": "supera el límite de archivos de {{fileSizeLimit}}",
"publish_dialog_attachment_limits_quota_reached": "supera la cuota, restan {{remainingBytes}}",
"publish_dialog_priority_min": "Prioridad mínima",
"publish_dialog_priority_default": "Prioridad predeterminada",
"publish_dialog_priority_max": "Prioridad máxima",
"publish_dialog_base_url_label": "URL del servicio",
"publish_dialog_base_url_placeholder": "URL del servicio, por ejemplo, https://example.com",
"publish_dialog_topic_label": "Nombre del tópico",
"publish_dialog_topic_placeholder": "Nombre del tópico, ej. phil_alerts",
"publish_dialog_title_label": "Título",
"publish_dialog_message_label": "Mensaje",
"publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, por ejemplo: warning, srv1-backup",
"publish_dialog_click_label": "Click URL",
"publish_dialog_click_placeholder": "URL que se abre cuando se hace click en la notificación",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Dirección a la que se reenviará la notificación, por ejemplo, phil@example.com",
"publish_dialog_attach_label": "URL del archivo adjunto",
"publish_dialog_filename_label": "Nombre del archivo",
"publish_dialog_delay_placeholder": "Retraso en la entrega, por ejemplo, {{unixTimestamp}}, {{relativeTime}}, o \"{{naturalLanguage}}\" (sólo en inglés)",
"publish_dialog_other_features": "Otras características:",
"publish_dialog_chip_click_label": "Click URL",
"publish_dialog_chip_email_label": "Reenviar al email",
"publish_dialog_chip_attach_url_label": "Adjuntar un archivo por URL",
"publish_dialog_chip_attach_file_label": "Adjuntar archivo local",
"publish_dialog_chip_topic_label": "Cambiar de tópico",
"publish_dialog_button_cancel_sending": "Cancelar el envío",
"publish_dialog_button_cancel": "Cancelar",
"publish_dialog_checkbox_publish_another": "Publicar otro",
"publish_dialog_attached_file_title": "Archivo adjunto:",
"publish_dialog_attached_file_filename_placeholder": "Nombre del archivo adjunto",
"publish_dialog_drop_file_here": "Suelta el archivo aquí",
"emoji_picker_search_placeholder": "Buscar emojis",
"subscribe_dialog_subscribe_title": "Suscribirse al tópico",
"subscribe_dialog_subscribe_description": "Los tópicos pueden no estar protegidos por contraseña, así que elija un nombre que no sea fácil de adivinar. Una vez suscrito, puede hacer PUT/PIST de notificaciones.",
"subscribe_dialog_subscribe_topic_placeholder": "Nombre del tópico, ej. phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Usar otro servidor",
"subscribe_dialog_login_title": "Es necesario iniciar sesión",
"subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.",
"subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil",
"subscribe_dialog_login_password_label": "Contraseña",
"subscribe_dialog_login_button_back": "Volver",
"subscribe_dialog_login_button_login": "Iniciar sesión",
"subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado",
"subscribe_dialog_error_user_anonymous": "anónimo",
"prefs_notifications_title": "Notificaciones",
"prefs_notifications_sound_title": "Sonido de notificación",
"prefs_notifications_min_priority_any": "Cualquier prioridad",
"prefs_notifications_min_priority_low_and_higher": "Prioridad baja y superior",
"prefs_notifications_min_priority_max_only": "Solo prioridad máxima",
"prefs_notifications_delete_after_title": "Eliminar notificaciones",
"prefs_notifications_delete_after_never": "Nunca",
"prefs_notifications_delete_after_three_hours": "Después de tres horas",
"prefs_notifications_delete_after_one_day": "Después de un día",
"prefs_notifications_delete_after_one_week": "Después de una semana",
"prefs_notifications_delete_after_one_month": "Después de un mes",
"prefs_users_title": "Administrar usuarios",
"prefs_users_description": "Añada/elimine usuarios para sus tópicos protegidos aquí. Tenga en cuenta que el nombre de usuario y la contraseña se guardan en el almacenamiento local del navegador.",
"prefs_users_add_button": "Añadir usuario",
"prefs_users_dialog_title_edit": "Editar usuario",
"prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh",
"prefs_users_dialog_button_add": "Añadir",
"prefs_users_dialog_button_save": "Guardar",
"prefs_appearance_title": "Apariencia",
"prefs_appearance_language_title": "Idioma",
"error_boundary_title": "Oh no, ntfy tuvo un error",
"error_boundary_button_copy_stack_trace": "Copiar el stack trace",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_gathering_info": "Reunir más información …",
"notifications_example": "Ejemplo",
"prefs_notifications_min_priority_title": "Prioridad mínima",
"notifications_none_for_any_description": "Para enviar notificaciones a un tópico, simplemente realice un PUT o POST a la URL del tópico. Aquí hay un ejemplo usando uno de sus tópicos.",
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
"subscribe_dialog_subscribe_button_subscribe": "Suscribir",
"publish_dialog_message_placeholder": "Escriba un mensaje aquí",
"publish_dialog_tags_label": "Etiquetas",
"publish_dialog_priority_label": "Prioridad",
"publish_dialog_priority_low": "Prioridad baja",
"publish_dialog_priority_high": "Prioridad alta",
"publish_dialog_delay_label": "Retraso",
"publish_dialog_title_placeholder": "Título de la notificación, por ejemplo, Alerta de espacio en disco",
"publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.",
"publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_placeholder": "Nombre del archivo adjunto",
"publish_dialog_chip_delay_label": "Retraso en la entrega",
"prefs_notifications_min_priority_default_and_higher": "Prioridad predeterminada y superior",
"prefs_notifications_min_priority_high_and_higher": "Prioridad alta y superior",
"prefs_users_table_user_header": "Usuario",
"prefs_users_table_base_url_header": "URL del servicio",
"publish_dialog_button_send": "Enviar",
"prefs_notifications_sound_no_sound": "Sin sonido",
"prefs_users_dialog_password_label": "Contraseña",
"error_boundary_description": "Obviamente, esto no debería ocurrir. Lo sentimos mucho.<br/>Si tienes un minuto, por favor <githubLink>informa de esto en GitHub</githubLink>, o avísanos vía <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
"prefs_users_dialog_title_add": "Añadir usuario",
"prefs_users_dialog_button_cancel": "Cancelar",
"prefs_users_dialog_username_label": "Nombre de usuario, ej. phil",
"priority_max": "máx",
"priority_high": "alta",
"prefs_notifications_delete_after_one_month_description": "Las notificaciones se eliminan automáticamente después de un mes",
"priority_min": "mín",
"prefs_notifications_delete_after_three_hours_description": "Las notificaciones se eliminan automáticamente después de tres horas",
"prefs_notifications_sound_description_none": "Las notificaciones no reproducen ningún sonido cuando llegan",
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificaciones si la prioridad es {{number}} ({{name}}) o superior",
"prefs_notifications_min_priority_description_max": "Mostrar notificaciones si la prioridad es 5 (máxima)",
"prefs_notifications_sound_description_some": "Las notificaciones reproducen el sonido {{sound}} cuando llegan",
"prefs_notifications_min_priority_description_any": "Mostrando todas las notificaciones, independientemente de su prioridad",
"prefs_notifications_delete_after_never_description": "Las notificaciones nunca se borran automáticamente",
"priority_default": "predeterminada",
"prefs_notifications_delete_after_one_day_description": "Las notificaciones se eliminan automáticamente después de un día",
"prefs_notifications_delete_after_one_week_description": "Las notificaciones se eliminan automáticamente después de una semana",
"priority_low": "baja",
"notifications_actions_not_supported": "Acción no soportada en la aplicación web",
"notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}"
}

View File

@@ -0,0 +1,156 @@
{
"nav_topics_title": "Sujets souscrits",
"action_bar_settings": "Paramètres",
"action_bar_send_test_notification": "Envoyer une notification de test",
"action_bar_clear_notifications": "Effacer toutes les notifications",
"action_bar_unsubscribe": "Se désabonner",
"message_bar_type_message": "Tapez un message ici",
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
"notifications_attachment_link_expires": "le lien expire {{date}}",
"message_bar_error_publishing": "Notification d'erreur de publication",
"nav_button_all_notifications": "Toutes les notifications",
"nav_button_settings": "Paramètres",
"nav_button_documentation": "Documentation",
"alert_not_supported_description": "Les notifications ne sont pas prises en charge par votre navigateur.",
"notifications_attachment_copy_url_title": "Copier l'URL de la pièce jointe dans le presse-papiers",
"notifications_attachment_open_title": "Aller à {{url}}",
"notifications_attachment_link_expired": "lien de téléchargement expiré",
"nav_button_publish_message": "Publier la notification",
"notifications_copied_to_clipboard": "Copié dans le presse-papiers",
"alert_not_supported_title": "Notifications non prises en charge",
"notifications_tags": "Étiquettes",
"notifications_attachment_copy_url_button": "Copier l'URL",
"notifications_click_copy_url_title": "Copier l'URL du lien dans le presse-papiers",
"notifications_click_copy_url_button": "Copier le lien",
"notifications_click_open_button": "Ouvrir le lien",
"notifications_none_for_topic_title": "Vous n'avez pas encore reçu de notifications pour ce sujet.",
"notifications_actions_open_url_title": "Aller à {{url}}",
"notifications_example": "Exemple",
"notifications_loading": "Chargement des notifications…",
"publish_dialog_progress_uploading": "Téléversement…",
"publish_dialog_priority_min": "Priorité minimum",
"publish_dialog_priority_low": "Basse priorité",
"publish_dialog_priority_default": "Priorité par défaut",
"publish_dialog_base_url_label": "URL du service",
"publish_dialog_base_url_placeholder": "URL du service, par ex. https://exemple.com",
"publish_dialog_title_label": "Titre",
"publish_dialog_message_label": "Message",
"publish_dialog_topic_label": "Nom du sujet",
"publish_dialog_message_placeholder": "Tapez un message ici",
"publish_dialog_tags_label": "Étiquettes",
"publish_dialog_email_label": "Courriel",
"publish_dialog_email_placeholder": "Adresse à laquelle transmettre la notification, par exemple phil@exemple.com",
"publish_dialog_chip_email_label": "Transférer vers le courriel",
"notifications_no_subscriptions_title": "Il semble que vous nayez pas encore dabonnements.",
"publish_dialog_progress_uploading_detail": "Téléversement {{loaded}}/{{total}} ({{percent}} %) …",
"publish_dialog_message_published": "Notification publiée",
"publish_dialog_attachment_limits_file_and_quota_reached": "dépasse la limite et le quota du fichier {{fileSizeLimit}}, {{remainingBytes}} restant",
"publish_dialog_priority_high": "Haute priorité",
"publish_dialog_priority_max": "Priorité maximum",
"publish_dialog_attachment_limits_file_reached": "Dépasse la limite du fichier {{fileSizeLimit}}",
"nav_button_subscribe": "S'abonner au sujet",
"notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
"alert_grant_title": "Les notifications sont désactivées",
"alert_grant_description": "Autorisez votre navigateur à afficher les notifications du bureau.",
"alert_grant_button": "Accorder maintenant",
"notifications_none_for_any_title": "Vous n'avez reçu aucune notification.",
"publish_dialog_title_topic": "Publier vers {{topic}}",
"publish_dialog_title_no_topic": "Publier la notification",
"notifications_more_details": "Pour plus d'information, visitez <websiteLink>le site web</websiteLink> ou <docsLink>la documentation</docsLink>.",
"publish_dialog_title_placeholder": "Titre de la notification, par ex. Alerte d'espace disque",
"publish_dialog_topic_placeholder": "Nom du sujet, par ex. phil_alerts",
"publish_dialog_delay_placeholder": "Délai de la délivrance, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
"publish_dialog_other_features": "Autres fonctionnalités :",
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
"publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1",
"publish_dialog_priority_label": "Priorité",
"publish_dialog_click_label": "Cliquer sur l'URL",
"publish_dialog_click_placeholder": "URL ouverte quand la notification est cliquée",
"publish_dialog_attach_label": "URL de la pièce jointe",
"publish_dialog_attach_placeholder": "Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_label": "Nom du fichier",
"notifications_none_for_topic_description": "Pour envoyer des notifications à ce sujet, faites simplement une requête PUT ou POST à l'URL du sujet.",
"notifications_none_for_any_description": "Pour envoyer des notifications à un sujet, faites simplement une requête PUT ou POST à l'URL du sujet. Voici un exemple utilisant un de vos sujets.",
"publish_dialog_filename_placeholder": "Nom du fichier joint",
"publish_dialog_delay_label": "Délai",
"publish_dialog_chip_click_label": "Cliquez sur l'URL",
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
"subscribe_dialog_login_title": "Connexion nécessaire",
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
"prefs_users_dialog_button_cancel": "Annuler",
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
"publish_dialog_attached_file_title": "Fichier joint :",
"publish_dialog_checkbox_publish_another": "Publier un autre",
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
"subscribe_dialog_subscribe_use_another_label": "Utiliser un autre serveur",
"subscribe_dialog_subscribe_button_cancel": "Annuler",
"prefs_notifications_sound_description_none": "Les notifications ne font aucun son quand elles arrivent",
"prefs_notifications_sound_description_some": "Les notifications jouent le son {{sound}} quand elles arrivent",
"prefs_notifications_min_priority_description_x_or_higher": "Montrer les notifications si leur priorité est {{number}} ({{name}}) ou plus",
"publish_dialog_button_cancel": "Annuler",
"publish_dialog_button_send": "Envoyer",
"publish_dialog_drop_file_here": "Déposez un fichier ici",
"emoji_picker_search_placeholder": "Chercher un émoji",
"subscribe_dialog_subscribe_description": "Le sujet n'est peut-être pas protégé par un mot de passe, choisissez un nom de sujet difficile à deviner. Une fois abonné, vous pouvez PUT/POST des notifications.",
"subscribe_dialog_subscribe_topic_placeholder": "Nom de sujet, par ex. alertes_de_phil",
"subscribe_dialog_subscribe_button_subscribe": "S'abonner",
"subscribe_dialog_login_description": "Ce sujet est protégé par un mot de passe. Veuillez entrer le nom d'utilisateur et le mot de passe pour vous abonner.",
"subscribe_dialog_login_username_label": "Nom d'utilisateur, par ex. phil",
"subscribe_dialog_login_button_login": "Connexion",
"prefs_notifications_sound_title": "Son de notification",
"prefs_notifications_delete_after_never": "Jamais",
"prefs_users_table_base_url_header": "URL de service",
"subscribe_dialog_login_password_label": "Mot de passe",
"prefs_notifications_title": "Notifications",
"prefs_notifications_delete_after_title": "Supprimer les notifications",
"prefs_users_add_button": "Ajouter un utilisateur",
"subscribe_dialog_login_button_back": "Retour",
"subscribe_dialog_error_user_anonymous": "anonyme",
"prefs_notifications_sound_no_sound": "Aucun son",
"prefs_notifications_min_priority_title": "Priorité minimum",
"prefs_notifications_min_priority_description_any": "Montrer toutes les notifications, quelque soit leur priorité",
"prefs_notifications_min_priority_description_max": "Montrer les notifications si la priorité est 5 (max)",
"prefs_notifications_min_priority_default_and_higher": "Priorité par défaut et au-dessus",
"prefs_notifications_min_priority_max_only": "Seulement la priorité maximale",
"prefs_notifications_delete_after_three_hours": "Après trois heures",
"prefs_notifications_delete_after_one_day": "Après un jour",
"subscribe_dialog_error_user_not_authorized": "L'utilisateur {{username}} n'est pas autorisé",
"prefs_notifications_min_priority_any": "N'importe quelle priorité",
"prefs_notifications_min_priority_high_and_higher": "Priorité haute et au-dessus",
"prefs_users_dialog_base_url_label": "URL du service, par ex. https://ntfy.sh",
"prefs_notifications_delete_after_one_week_description": "Les notifications sont supprimées automatiquement après une semaine",
"prefs_users_dialog_username_label": "Nom d'utilisateur, par ex. phil",
"prefs_users_dialog_password_label": "Mot de passe",
"prefs_notifications_delete_after_one_month_description": "Les notifications sont supprimées automatiquement après un mois",
"prefs_users_title": "Gérer les utilisateurs",
"prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.",
"prefs_users_table_user_header": "Utilisateur",
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
"prefs_users_dialog_button_add": "Ajouter",
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.",
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_gathering_info": "Récupérer plus d'information…",
"prefs_notifications_delete_after_one_week": "Après une semaine",
"prefs_notifications_delete_after_one_month": "Après un mois",
"prefs_notifications_delete_after_never_description": "Les notifications ne sont jamais supprimées automatiquement",
"prefs_notifications_delete_after_three_hours_description": "Les notifications sont supprimées automatiquement après trois heures",
"prefs_notifications_delete_after_one_day_description": "Les notifications sont supprimées automatiquement après un jour",
"prefs_appearance_title": "Apparence",
"prefs_appearance_language_title": "Langue",
"priority_min": "min",
"priority_low": "basse",
"priority_default": "défault",
"priority_high": "haute",
"priority_max": "max",
"error_boundary_title": "Oh non, ntfy a planté",
"publish_dialog_chip_attach_url_label": "Joindre un fichier par URL",
"publish_dialog_chip_attach_file_label": "Joindre un fichier local",
"publish_dialog_chip_delay_label": "Délayer l'envoi",
"publish_dialog_chip_topic_label": "Changer de sujet",
"publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.",
"publish_dialog_button_cancel_sending": "Annuler l'envoi",
"prefs_users_dialog_button_save": "Enregistrer"
}

View File

@@ -0,0 +1,156 @@
{
"action_bar_send_test_notification": "Teszt értesítés küldése",
"action_bar_clear_notifications": "Összes értesítés törlése",
"alert_not_supported_description": "A böngésző nem támogatja az értesítések fogadását.",
"action_bar_settings": "Beállítások",
"action_bar_unsubscribe": "Leiratkozás",
"message_bar_type_message": "Írd ide az üzenetet",
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
"nav_button_all_notifications": "Összes értesítés",
"nav_topics_title": "Feliratkozott témák",
"alert_grant_title": "Az értesítések le vannak tiltva",
"alert_grant_description": "Engedélyezd a böngészőnek, hogy asztali értesítéseket jeleníttessen meg.",
"nav_button_settings": "Beállítások",
"nav_button_documentation": "Dokumentáció",
"nav_button_publish_message": "Értesítés küldése",
"alert_grant_button": "Engedélyezés",
"alert_not_supported_title": "Nem támogatott funkció",
"notifications_copied_to_clipboard": "Másolva a vágólapra",
"notifications_tags": "Címkék",
"notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét",
"notifications_attachment_copy_url_button": "URL másolása",
"notifications_attachment_open_title": "Menjen a(z) {{url}} címre",
"notifications_attachment_open_button": "Csatolmány megnyitása",
"notifications_attachment_link_expired": "A letöltési hivatkozás lejárt",
"notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le",
"nav_button_subscribe": "Feliratkozás a témára",
"notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét",
"notifications_actions_open_url_title": "Menjen a(z) {{url}} címre",
"notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban",
"notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre",
"notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.",
"notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.",
"notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.",
"notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.",
"publish_dialog_message_published": "Értesítés elküldve",
"notifications_example": "Példa",
"notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel lehet értesítéseket küldeni rá, amik itt fognak megjelenni.",
"publish_dialog_priority_low": "Alacsony prioritás",
"publish_dialog_priority_default": "Közepes prioritás",
"publish_dialog_priority_high": "Magas prioritás",
"notifications_more_details": "További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.",
"publish_dialog_title_no_topic": "Értesítés küldése",
"publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)",
"publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt",
"publish_dialog_priority_min": "Legkisebb prioritás",
"publish_dialog_base_url_label": "A szolgáltatás URL-e",
"publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com",
"publish_dialog_topic_label": "Téma neve",
"publish_dialog_priority_max": "Legmagasabb prioritás",
"publish_dialog_topic_placeholder": "Téma neve, pl: józsi_riasztásai",
"publish_dialog_title_label": "Cím",
"publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely",
"publish_dialog_message_label": "Üzenet",
"publish_dialog_message_placeholder": "Írj ide egy üzenetet",
"publish_dialog_tags_label": "Címkék",
"publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup",
"publish_dialog_priority_label": "Prioritás",
"publish_dialog_click_label": "URL",
"publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: fulop@example.com",
"publish_dialog_attach_label": "Csatolmány URL-e",
"publish_dialog_filename_label": "Fájlnév",
"publish_dialog_filename_placeholder": "Csatolmány fájlneve",
"publish_dialog_delay_label": "Késleltetés",
"publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)",
"publish_dialog_other_features": "Egyéb lehetőségek:",
"publish_dialog_chip_click_label": "Kattintási URL",
"publish_dialog_chip_attach_file_label": "Helyi fájl csatolása",
"publish_dialog_chip_delay_label": "Késleltetett kézbesítés",
"publish_dialog_chip_topic_label": "Téma megváltoztatása",
"publish_dialog_button_cancel_sending": "Küldés megállítása",
"publish_dialog_button_cancel": "Mégsem",
"publish_dialog_checkbox_publish_another": "Küldök még egyet",
"publish_dialog_attached_file_title": "Csatolt fájl:",
"publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve",
"publish_dialog_drop_file_here": "Ejtsd ide a fájlt",
"emoji_picker_search_placeholder": "Emoji keresése",
"publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.",
"subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert",
"subscribe_dialog_subscribe_button_subscribe": "Feliratkozás",
"subscribe_dialog_login_title": "Be kell jelentkezni",
"subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.",
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
"subscribe_dialog_login_password_label": "Jelszó",
"subscribe_dialog_login_button_back": "Vissza",
"subscribe_dialog_login_button_login": "Belépés",
"subscribe_dialog_error_user_anonymous": "névtelen",
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
"prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül",
"prefs_notifications_min_priority_description_max": "Csak az 5-ös (Legmagasabb) prioritású értesítések jelennek meg",
"prefs_notifications_min_priority_any": "Bármilyen prioritás",
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb",
"prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás",
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás",
"prefs_notifications_sound_title": "Értesítés hangja",
"prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek",
"prefs_notifications_sound_no_sound": "Hang nélkül",
"prefs_notifications_delete_after_one_week": "1 hét után",
"prefs_notifications_delete_after_one_month": "1 hónap után",
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve",
"prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése",
"prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése",
"prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.",
"prefs_users_table_user_header": "Felhasználó",
"prefs_users_table_base_url_header": "Szerver címe",
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
"prefs_users_dialog_password_label": "Jelszó",
"prefs_users_dialog_button_add": "Hozzáadás",
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
"notifications_loading": "Értesítések betöltése …",
"publish_dialog_progress_uploading": "Feltöltés …",
"notifications_click_copy_url_button": "Hivatkozás másolása",
"notifications_click_open_button": "Hivatkozás megnyitása",
"publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%)",
"notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.",
"prefs_notifications_delete_after_one_day": "1 nap után",
"publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk",
"publish_dialog_chip_email_label": "Továbbítás email-ben",
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel",
"publish_dialog_button_send": "Küldés",
"subscribe_dialog_subscribe_title": "Feliratkozás témára",
"subscribe_dialog_subscribe_button_cancel": "Mégsem",
"prefs_notifications_min_priority_title": "Legkisebb piroritás",
"prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb",
"prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb",
"prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése",
"prefs_users_add_button": "Felhasználó hozzáadása",
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: józsi_riasztásai",
"prefs_notifications_title": "Értesítések",
"error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása",
"prefs_notifications_delete_after_title": "Régi értesítések törlése",
"prefs_notifications_delete_after_three_hours": "3 óra után",
"error_boundary_title": "Jaj ne, az ntfy összeomlott",
"prefs_notifications_delete_after_never": "Soha",
"prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése",
"prefs_appearance_title": "Megjelenés",
"priority_default": "közepes",
"priority_high": "magas",
"priority_max": "legmagasabb",
"priority_min": "legkisebb",
"error_boundary_gathering_info": "Több információ…",
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
"prefs_users_title": "Felhasználók kezelése",
"prefs_users_dialog_button_cancel": "Mégsem",
"prefs_users_dialog_button_save": "Mentés",
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
"prefs_appearance_language_title": "Nyelv",
"priority_low": "alacsony",
"error_boundary_stack_trace": "Verem nyomkövetés",
"publish_dialog_title_topic": "A {{topic}} téma értesítése",
"prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni",
"error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>."
}

View File

@@ -0,0 +1,156 @@
{
"notifications_click_copy_url_title": "Salin URL tautan ke papan klip",
"alert_not_supported_title": "Notifikasi tidak didukung",
"notifications_click_copy_url_button": "Salin tautan",
"notifications_no_subscriptions_description": "Klik pada tautan \"{{linktext}}\" untuk membuat atau berlangganan ke sebuah topik. Setelah itu, Anda dapat mengirim pesan via PUT atau POST dan Anda akan menerima notifikasi di sini.",
"notifications_example": "Contoh",
"subscribe_dialog_subscribe_description": "Topik mungkin tidak dilindungi oleh kata sandi, jadi pilih sebuah nama yang tidak mudah untuk ditebak. Setelah berlangganan, Anda dapat PUT/POST notifikasi.",
"subscribe_dialog_login_title": "Login dibutuhkan",
"prefs_appearance_language_title": "Bahasa",
"nav_button_all_notifications": "Semua notifikasi",
"notifications_none_for_any_title": "Anda belum menerima notifikasi apa pun.",
"action_bar_settings": "Pengaturan",
"action_bar_send_test_notification": "Kirim notifikasi uji coba",
"action_bar_clear_notifications": "Hapus semua notifikasi",
"action_bar_unsubscribe": "Batalkan langganan",
"message_bar_type_message": "Ketika sebuah pesan di sini",
"message_bar_error_publishing": "Terjadi kesalahan mempublikasikan notifikasi",
"publish_dialog_title_label": "Judul",
"publish_dialog_message_label": "Pesan",
"nav_button_settings": "Pengaturan",
"nav_button_documentation": "Dokumentasi",
"prefs_users_dialog_button_add": "Tambahkan",
"nav_topics_title": "Topik yang dilanggani",
"nav_button_subscribe": "Berlangganan ke topik",
"alert_grant_title": "Notifikasi dinonaktifkan",
"alert_grant_description": "Berikan izin ke peramban untuk menampilkan notifikasi desktop.",
"alert_not_supported_description": "Notifikasi tidak didukung dalam peramban Anda.",
"notifications_attachment_open_title": "Pergi ke {{url}}",
"notifications_attachment_open_button": "Buka lampiran",
"notifications_attachment_link_expires": "tautan kadaluwarsa {{date}}",
"notifications_attachment_link_expired": "tautan unduhan kadaluwarsa",
"notifications_actions_open_url_title": "Pergi ke {{url}}",
"notifications_click_open_button": "Buka tautan",
"publish_dialog_topic_placeholder": "Nama topik, mis. pemberitahuan_andi",
"nav_button_publish_message": "Publikasikan notifikasi",
"alert_grant_button": "Berikan sekarang",
"notifications_copied_to_clipboard": "Disalin ke papan klip",
"notifications_tags": "Tanda",
"notifications_attachment_copy_url_title": "Salin URL lampiran ke papan klip",
"notifications_attachment_copy_url_button": "Salin URL",
"notifications_none_for_topic_title": "Anda belum menerima notifikasi apa pun untuk topik ini.",
"notifications_none_for_topic_description": "Untuk mengirimkan notifikasi ke topik ini, tinggal PUT atau POST ke URL topik.",
"notifications_none_for_any_description": "Untuk mengirimkan notifikasi ke sebuah topik, tinggal PUT atau POST ke URL topik. Ini adalah contoh menggunakan salah satu topik Anda.",
"notifications_no_subscriptions_title": "Sepertinya Anda belum memiliki langganan apa pun.",
"publish_dialog_title_topic": "Publikasikan ke {{topic}}",
"subscribe_dialog_login_description": "Topik ini dilindungi oleh kata sandi. Mohon masukkan nama pengguna dan kata sandi untuk berlangganan.",
"prefs_notifications_min_priority_title": "Prioritas minimum",
"error_boundary_gathering_info": "Dapatkan info lanjut …",
"publish_dialog_title_no_topic": "Publikasikan notifikasi",
"publish_dialog_progress_uploading": "Mengunggah …",
"notifications_more_details": "Untuk informasi lanjut, lihat <websiteLink>situs web</websiteLink> atau <docsLink>dokumentasi</docsLink>.",
"publish_dialog_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Notifikasi terpublikasi",
"notifications_loading": "Memuat notifikasi …",
"publish_dialog_base_url_label": "URL Layanan",
"publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
"publish_dialog_tags_label": "Tanda",
"publish_dialog_priority_label": "Prioritas",
"publish_dialog_base_url_placeholder": "URL Layanan, mis. https://contoh.com",
"publish_dialog_attach_placeholder": "Lampirkan file dengan URL, mis. https://f-droid.org/F-Droid.apk",
"publish_dialog_delay_label": "Jeda",
"publish_dialog_chip_topic_label": "Ubah topik",
"publish_dialog_button_cancel_sending": "Batalkan pengiriman",
"publish_dialog_button_send": "Kirim",
"publish_dialog_attachment_limits_file_reached": "melebihi batasan file {{fileSizeLimit}",
"publish_dialog_attachment_limits_file_and_quota_reached": "melebihi batasan file dan kuota {{fileSizeLimit}}, hanya {{remainingBytes}}",
"publish_dialog_attachment_limits_quota_reached": "melebihi kuota, hanya {{remainingBytes}}",
"publish_dialog_priority_min": "Prioritas min.",
"publish_dialog_priority_low": "Prioritas rendah",
"publish_dialog_priority_default": "Prioritas bawaan",
"publish_dialog_priority_high": "Prioritas tinggi",
"publish_dialog_priority_max": "Prioritas maks.",
"publish_dialog_topic_label": "Nama topik",
"publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
"publish_dialog_click_label": "Klik URL",
"publish_dialog_tags_placeholder": "Daftar tanda yang dipisah dengan koma, mis. peringatan, cadangan-srv1",
"publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik",
"publish_dialog_email_label": "Email",
"publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, mis. andi@contoh.com",
"publish_dialog_attach_label": "URL Lampiran",
"publish_dialog_filename_label": "Nama File",
"publish_dialog_filename_placeholder": "Nama file lampiran",
"publish_dialog_delay_placeholder": "Penjedaan pengiriman, mis. {{unixTimestamp}}, {{relativeTime}}, atau \"{{naturalLanguage}}\" (hanya Inggris)",
"publish_dialog_other_features": "Fitur lainnya:",
"publish_dialog_chip_click_label": "Klik URL",
"publish_dialog_chip_email_label": "Teruskan ke email",
"publish_dialog_chip_attach_url_label": "Lampirkan file dengan URL",
"publish_dialog_chip_attach_file_label": "Lampirkan file lokal",
"publish_dialog_chip_delay_label": "Jeda pengiriman",
"publish_dialog_button_cancel": "Batal",
"publish_dialog_details_examples_description": "Untuk contoh dan deskripsi yang rinci oleh semua fitur pengiriman, lihat <docsLink>dokumentasi</docsLink>.",
"publish_dialog_checkbox_publish_another": "Publikasi yang lain",
"publish_dialog_attached_file_title": "File yang dilampirkan:",
"publish_dialog_attached_file_filename_placeholder": "Nama file lampiran",
"publish_dialog_drop_file_here": "Lepaskan file di sini",
"emoji_picker_search_placeholder": "Cari emoji",
"subscribe_dialog_subscribe_button_cancel": "Batal",
"subscribe_dialog_subscribe_button_subscribe": "Berlangganan",
"subscribe_dialog_error_user_anonymous": "anonim",
"prefs_notifications_min_priority_any": "Prioritas apa saja",
"prefs_notifications_delete_after_title": "Hapus notifikasi",
"prefs_notifications_delete_after_three_hours": "Setelah tiga jam",
"prefs_notifications_delete_after_one_day": "Setelah satu hari",
"prefs_users_add_button": "Tambahkan pengguna",
"prefs_users_dialog_username_label": "Nama pengguna, mis. andi",
"subscribe_dialog_subscribe_title": "Berlangganan ke topik",
"subscribe_dialog_subscribe_topic_placeholder": "Nama topik, mis. pemberitahuan_andi",
"subscribe_dialog_subscribe_use_another_label": "Gunakan server lain",
"subscribe_dialog_login_username_label": "Nama pengguna, mis. Andi",
"subscribe_dialog_login_button_login": "Masuk",
"subscribe_dialog_error_user_not_authorized": "Pengguna {{username}} tidak diizinkan",
"prefs_notifications_title": "Notifikasi",
"prefs_notifications_sound_no_sound": "Tidak ada suara",
"prefs_users_table_user_header": "Pengguna",
"prefs_users_dialog_base_url_label": "URL Layanan, mis. https://ntfy.sh",
"prefs_users_dialog_button_save": "Simpan",
"prefs_appearance_title": "Tampilan",
"subscribe_dialog_login_password_label": "Kata sandi",
"subscribe_dialog_login_button_back": "Kembali",
"prefs_notifications_sound_title": "Suara notifikasi",
"prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi",
"prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi",
"prefs_notifications_min_priority_high_and_higher": "Prioritas tinggi dan lebih tinggi",
"prefs_notifications_min_priority_max_only": "Hanya prioritas maks",
"prefs_notifications_delete_after_never": "Tidak pernah",
"prefs_notifications_delete_after_one_week": "Setelah satu minggu",
"prefs_notifications_delete_after_one_month": "Setelah satu bulan",
"prefs_users_title": "Kelola pengguna",
"prefs_users_description": "Tambahkan/hapus pengguna untuk topik yang dilindungi di sini. Dicatat bahwa nama pengguna dan kata sandi disimpan dalam penyimpanan lokal peramban.",
"prefs_users_table_base_url_header": "URL Layanan",
"prefs_users_dialog_title_add": "Tambahkan pengguna",
"prefs_users_dialog_title_edit": "Edit pengguna",
"prefs_users_dialog_password_label": "Kata sandi",
"prefs_users_dialog_button_cancel": "Batal",
"error_boundary_title": "Aduh, ntfy mogok",
"error_boundary_description": "Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.<br/>Jika Anda punya beberapa menit, silakan <githubLink>laporkan ini di GitHub</githubLink>, atau beritahu kami melalui <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
"error_boundary_stack_trace": "Jejak tumpukan",
"error_boundary_button_copy_stack_trace": "Salin jejak tumpukan",
"prefs_notifications_sound_description_some": "Notifikasi memainkan suara {{sound}} ketika diterima",
"prefs_notifications_min_priority_description_any": "Menampilkan semua notifikasi, apa pun prioritasnya",
"prefs_notifications_min_priority_description_max": "Tampilkan notifikasi jika prioritas adalah 5 (maks)",
"prefs_notifications_delete_after_three_hours_description": "Notifikasi dihapus secara otomatis setelah tiga jam",
"prefs_notifications_delete_after_one_week_description": "Notifikasi dihapus secara otomatis setelah satu minggu",
"prefs_notifications_delete_after_one_month_description": "Notifikasi dihapus secara otomatis setelah satu bulan",
"priority_low": "rendah",
"priority_high": "tinggi",
"priority_max": "maks",
"prefs_notifications_min_priority_description_x_or_higher": "Tampilkan notifikasi jika prioritas {{number}} ({{name}}) atau lebih",
"prefs_notifications_sound_description_none": "Notifikasi tidak boleh memainkan suara apa pun ketika diterima",
"prefs_notifications_delete_after_never_description": "Notifikasi tidak pernah dihapus secara otomatis",
"prefs_notifications_delete_after_one_day_description": "Notifikasi dihapus secara otomatis setelah satu hari",
"priority_default": "bawaan",
"priority_min": "min",
"notifications_actions_not_supported": "Tindakan tidak didukung di aplikasi web",
"notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}"
}

View File

@@ -0,0 +1,154 @@
{
"message_bar_error_publishing": "通知送信エラー",
"nav_button_all_notifications": "全ての通知",
"nav_button_settings": "設定",
"notifications_click_open_button": "リンクを開く",
"action_bar_send_test_notification": "テスト通知を送信",
"action_bar_clear_notifications": "全ての通知を消去",
"action_bar_unsubscribe": "購読解除",
"nav_button_documentation": "ドキュメント",
"alert_not_supported_description": "通知機能はこのブラウザではサポートされていません。",
"notifications_copied_to_clipboard": "クリップボードにコピーしました",
"notifications_example": "例",
"publish_dialog_title_topic": "{{topic}}に送信",
"publish_dialog_title_no_topic": "通知を送信",
"publish_dialog_progress_uploading": "アップロード中…",
"publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "通知を送信しました",
"publish_dialog_title_label": "タイトル",
"publish_dialog_filename_label": "ファイル名",
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
"subscribe_dialog_login_username_label": "ユーザー名, 例) phil",
"subscribe_dialog_login_password_label": "パスワード",
"subscribe_dialog_login_button_back": "戻る",
"subscribe_dialog_login_button_login": "ログイン",
"prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上",
"prefs_notifications_min_priority_max_only": "優先度最高のみ",
"action_bar_settings": "設定",
"message_bar_type_message": "メッセージを入力してください",
"nav_topics_title": "購読しているトピック",
"nav_button_subscribe": "トピックを購読",
"alert_grant_description": "ブラウザのデスクトップ通知を許可してください。",
"alert_grant_button": "許可する",
"notifications_attachment_link_expires": "リンクは {{date}} に失効します",
"notifications_click_copy_url_button": "リンクをコピー",
"notifications_none_for_topic_description": "トピックに通知を送信するには、トピックのURLにPUTかPOSTしてください。",
"nav_button_publish_message": "通知を送信",
"alert_grant_title": "通知は無効化されています",
"alert_not_supported_title": "通知機能はサポートされていません",
"notifications_tags": "タグ",
"notifications_attachment_copy_url_button": "URLをコピー",
"notifications_attachment_open_title": "{{url}} に移動",
"notifications_attachment_link_expired": "ダウンロードリンクは失効しました",
"notifications_actions_open_url_title": "{{url}} に移動",
"notifications_attachment_copy_url_title": "添付URLをクリップボードにコピー",
"notifications_attachment_open_button": "添付ファイルを開く",
"notifications_click_copy_url_title": "リンクURLをクリップボードにコピー",
"notifications_none_for_topic_title": "このトピックではまだ通知を受信していません。",
"notifications_no_subscriptions_description": "「{{linktext}}」リンクをクリックしてトピックを作成または購読してください。その後、メッセージをPUTまたはPOSTで送信すると通知が受信できます。",
"publish_dialog_message_label": "メッセージ",
"publish_dialog_email_label": "メール",
"notifications_none_for_any_title": "まだ通知を受信していません。",
"publish_dialog_priority_max": "優先度最高",
"publish_dialog_button_cancel_sending": "送信をキャンセル",
"publish_dialog_attach_label": "添付URL",
"notifications_none_for_any_description": "トピックに通知を送信するには、トピックURLにPUTまたはPOSTしてください。トピックのひとつを利用した例を示します。",
"notifications_no_subscriptions_title": "まだ何も購読していないようです。",
"publish_dialog_attachment_limits_file_and_quota_reached": "ファイル制限とクォータ {{fileSizeLimit}} を超えました、残り {{remainingBytes}}",
"publish_dialog_priority_label": "優先度",
"publish_dialog_click_label": "クリックURL",
"publish_dialog_email_placeholder": "通知を転送するアドレス, 例) phil@example.com",
"notifications_more_details": "詳しい情報は、<websiteLink>ウェブサイト</websiteLink> または <docsLink>ドキュメント</docsLink> を参照してください。",
"publish_dialog_attachment_limits_file_reached": "ファイルサイズ制限 {{fileSizeLimit}} を超えました",
"publish_dialog_priority_min": "優先度最低",
"publish_dialog_priority_low": "優先度低",
"publish_dialog_priority_default": "優先度通常",
"publish_dialog_base_url_label": "サービスURL",
"publish_dialog_other_features": "他の機能:",
"notifications_loading": "通知を読み込み中…",
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
"publish_dialog_priority_high": "優先度高",
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
"publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
"publish_dialog_tags_label": "タグ",
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください 例: warning, srv1-backup",
"publish_dialog_topic_label": "トピック名",
"publish_dialog_delay_label": "遅延",
"publish_dialog_click_placeholder": "通知をクリックしたときに開くURL",
"publish_dialog_filename_placeholder": "添付ファイルの名称",
"publish_dialog_button_send": "送信",
"publish_dialog_chip_click_label": "Click URL",
"publish_dialog_chip_email_label": "メールに転送",
"publish_dialog_details_examples_description": "送信機能の例や詳細な説明については、<docsLink>ドキュメント</docsLink>を参照してください。",
"error_boundary_description": "明らかに起きてはならないことです。本当に申し訳ありません。<br/>もし時間があれば、<githubLink>GitHubにこれを報告</githubLink>するか、<discordLink>Discord</discordLink>または<matrixLink>Matrix</matrixLink>で我々に知らせて下さい。",
"publish_dialog_chip_attach_url_label": "URLでファイルを添付",
"publish_dialog_chip_attach_file_label": "ローカルファイルを添付",
"publish_dialog_chip_topic_label": "トピックを変更",
"publish_dialog_chip_delay_label": "配信を遅延させる",
"publish_dialog_attached_file_title": "添付ファイル:",
"publish_dialog_button_cancel": "キャンセル",
"publish_dialog_checkbox_publish_another": "送信後開いたままにする",
"publish_dialog_attached_file_filename_placeholder": "添付ファイル名",
"emoji_picker_search_placeholder": "絵文字を検索",
"subscribe_dialog_subscribe_title": "トピックを購読",
"prefs_users_title": "ユーザー管理",
"publish_dialog_drop_file_here": "ここにファイルをドロップしてください",
"subscribe_dialog_subscribe_topic_placeholder": "トピック名 例: phils_alerts",
"prefs_notifications_min_priority_any": "全ての優先度",
"prefs_notifications_delete_after_three_hours": "3時間後",
"prefs_users_description": "保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。",
"prefs_users_add_button": "ユーザー追加",
"prefs_users_dialog_button_add": "追加",
"subscribe_dialog_subscribe_use_another_label": "他のサーバーを使用",
"subscribe_dialog_error_user_not_authorized": "ユーザー名 {{username}} は許可されていません",
"prefs_notifications_delete_after_one_week": "1週間後",
"prefs_notifications_delete_after_one_month": "1か月後",
"subscribe_dialog_subscribe_description": "トピックはパスワード保護されないので、推測されにくい名前にしてください。購読した後、PUT/POSTで通知を送信できます。",
"subscribe_dialog_subscribe_button_cancel": "キャンセル",
"subscribe_dialog_subscribe_button_subscribe": "購読",
"subscribe_dialog_login_title": "ログインが必要です",
"subscribe_dialog_error_user_anonymous": "匿名",
"prefs_notifications_title": "通知",
"prefs_notifications_min_priority_low_and_higher": "優先度低 およびそれ以上",
"prefs_notifications_delete_after_never": "削除しない",
"prefs_notifications_delete_after_one_day": "1日後",
"prefs_notifications_sound_title": "通知音",
"prefs_notifications_sound_no_sound": "サウンドなし",
"prefs_notifications_min_priority_title": "表示する優先度",
"prefs_notifications_min_priority_default_and_higher": "優先度通常 およびそれ以上",
"prefs_notifications_delete_after_title": "通知を削除",
"prefs_users_dialog_button_cancel": "キャンセル",
"prefs_users_dialog_button_save": "保存",
"prefs_users_table_user_header": "ユーザー名",
"prefs_users_dialog_title_add": "ユーザー追加",
"prefs_users_dialog_title_edit": "ユーザー編集",
"prefs_users_dialog_base_url_label": "サービスURL, 例) https://ntfy.sh",
"prefs_appearance_title": "外観",
"prefs_appearance_language_title": "言語",
"prefs_users_table_base_url_header": "サービスURL",
"prefs_users_dialog_username_label": "ユーザー名, 例) phil",
"prefs_users_dialog_password_label": "パスワード",
"error_boundary_title": "ああ、ntfyがクラッシュしました",
"error_boundary_button_copy_stack_trace": "スタックトレースをコピー",
"error_boundary_stack_trace": "スタックトレース",
"error_boundary_gathering_info": "更に情報を集める…",
"publish_dialog_base_url_placeholder": "サービスURL, 例) https://example.com",
"publish_dialog_attach_placeholder": "添付ファイルをURLで指定, 例) https://f-droid.org/F-Droid.apk",
"publish_dialog_delay_placeholder": "遅延時間, 例) {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
"prefs_notifications_sound_description_none": "通知受信時に音を鳴らしません",
"prefs_notifications_sound_description_some": "通知受信時に {{sound}} を鳴らします",
"prefs_notifications_min_priority_description_any": "優先度に関係なく全ての通知を表示します",
"prefs_notifications_min_priority_description_x_or_higher": "優先度が {{number}} ({{name}}) 以上の時に通知を表示します",
"prefs_notifications_delete_after_never_description": "通知は自動的に削除されません",
"prefs_notifications_delete_after_one_day_description": "通知は1日後に自動的に削除されます",
"prefs_notifications_delete_after_one_week_description": "通知は1週間後に自動的に削除されます",
"prefs_notifications_delete_after_one_month_description": "通知は1か月後に自動的に削除されます",
"priority_high": "高",
"priority_max": "最高",
"prefs_notifications_min_priority_description_max": "優先度が 5 (最高) の時にのみ通知を表示します",
"priority_default": "通常",
"prefs_notifications_delete_after_three_hours_description": "通知は3時間後に自動的に削除されます",
"priority_low": "低",
"priority_min": "最低"
}

View File

@@ -0,0 +1,126 @@
{
"nav_button_subscribe": "Abonner på emne",
"action_bar_settings": "Innstillinger",
"action_bar_send_test_notification": "Send testmerknad",
"action_bar_clear_notifications": "Tøm alle merknader",
"action_bar_unsubscribe": "Opphev abonnement",
"message_bar_type_message": "Skriv en melding her",
"nav_button_all_notifications": "Alle merknader",
"nav_button_settings": "Innstillinger",
"nav_button_documentation": "Dokumentasjon",
"nav_topics_title": "Abonnerte emner",
"alert_grant_title": "Merknader er avskrudd",
"alert_not_supported_title": "Merknader støttes ikke",
"notifications_copied_to_clipboard": "Kopiert til utklippstavlen",
"notifications_attachment_copy_url_title": "Kopier vedleggsnettadresse til utklippstavlen",
"notifications_attachment_copy_url_button": "Kopier nettadresse",
"notifications_attachment_open_button": "Åpne vedlegg",
"notifications_attachment_open_title": "Gå til {{url}}",
"notifications_attachment_link_expires": "lenken utløper {{date}}",
"notifications_click_copy_url_title": "Kopier lenke-nettadresse til utklippstavlen",
"notifications_actions_open_url_title": "Gå til {{url}}",
"notifications_tags": "Etiketter",
"notifications_attachment_link_expired": "nedlastingslenken har utløpt",
"notifications_none_for_any_title": "Du har ikke mottatt noen merknader.",
"notifications_click_open_button": "Åpne lenke",
"notifications_none_for_topic_title": "Du har ikke mottatt noen merknader for dette emnet enda.",
"notifications_example": "Eksempel",
"publish_dialog_title_topic": "Publiser til {{topic}}",
"publish_dialog_priority_min": "Min. prioritet",
"publish_dialog_priority_low": "Lav prioritet",
"publish_dialog_priority_default": "Forvalgt prioritet",
"publish_dialog_priority_high": "Høy prioritet",
"publish_dialog_priority_max": "Maks. prioritet",
"publish_dialog_base_url_label": "Tjeneste-nettadresse",
"publish_dialog_message_label": "Melding",
"publish_dialog_priority_label": "Prioritet",
"publish_dialog_tags_label": "Etiketter",
"publish_dialog_click_placeholder": "Nettadresse som åpnes når merknaden klikkes",
"publish_dialog_attach_label": "Vedleggs-nettadresse",
"publish_dialog_attach_placeholder": "Legg ved fil per nettadresse, f.eks. https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_label": "Filnavn",
"publish_dialog_delay_label": "Forsinkelse",
"publish_dialog_filename_placeholder": "Vedleggets filnavn",
"publish_dialog_other_features": "Andre funksjoner:",
"publish_dialog_chip_email_label": "Videresend til e-post",
"publish_dialog_chip_topic_label": "Endre emne",
"publish_dialog_button_cancel_sending": "Avbryt forsendelse",
"publish_dialog_chip_attach_file_label": "Legg ved lokal fil",
"publish_dialog_attached_file_title": "Vedlagt fil:",
"publish_dialog_attached_file_filename_placeholder": "Vedleggsfilnavn",
"subscribe_dialog_subscribe_use_another_label": "Bruk en annen tjener",
"subscribe_dialog_subscribe_button_cancel": "Avbryt",
"publish_dialog_drop_file_here": "Slipp filen her",
"subscribe_dialog_subscribe_title": "Abonner på emne",
"emoji_picker_search_placeholder": "Søk etter emoji",
"subscribe_dialog_login_button_login": "Logg inn",
"subscribe_dialog_subscribe_button_subscribe": "Abonner",
"subscribe_dialog_login_title": "Innlogging kreves",
"subscribe_dialog_login_username_label": "Brukernavn, f.eks. phil",
"subscribe_dialog_login_password_label": "Passord",
"prefs_notifications_title": "Merknader",
"prefs_notifications_sound_title": "Merknadslyd",
"prefs_notifications_sound_no_sound": "Ingen lyd",
"subscribe_dialog_error_user_anonymous": "anonym",
"error_boundary_stack_trace": "Stabelspor",
"error_boundary_button_copy_stack_trace": "Kopier stabelspor",
"message_bar_error_publishing": "Kunne ikke publisere merknader",
"nav_button_publish_message": "Publiser merknad",
"publish_dialog_title_no_topic": "Publiser merknad",
"publish_dialog_progress_uploading": "Laster opp …",
"publish_dialog_progress_uploading_detail": "Laster opp {{loaded}}/{{total}} ({{percent}}%) …",
"notifications_loading": "Laster inn merknader …",
"publish_dialog_message_published": "Merknad publisert",
"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_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",
"prefs_notifications_delete_after_one_day": "Etter én dag",
"prefs_notifications_delete_after_one_week": "Etter én uke",
"prefs_notifications_delete_after_one_month": "Etter én måned",
"prefs_notifications_delete_after_never_description": "Merknader blir aldri slettet automatisk",
"prefs_notifications_delete_after_three_hours_description": "Merknader slettes automatisk etter tre timer",
"prefs_users_title": "Håndter brukere",
"prefs_users_add_button": "Legg til bruker",
"prefs_users_table_user_header": "Bruker",
"prefs_users_dialog_title_add": "Legg til bruker",
"prefs_users_dialog_title_edit": "Rediger bruker",
"prefs_users_dialog_base_url_label": "Tjeneste-nettadresse, f.eks. https://ntfy.sh",
"prefs_users_dialog_password_label": "Passord",
"prefs_users_dialog_button_save": "Lagre",
"prefs_appearance_title": "Utseende",
"prefs_appearance_language_title": "Språk",
"prefs_users_dialog_username_label": "Brukernavn, f.eks. phil",
"priority_low": "lav",
"priority_default": "forvalg",
"priority_high": "høy",
"priority_max": "maks.",
"alert_grant_button": "Innvilg nå",
"publish_dialog_topic_label": "Emnenavn",
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
"notifications_click_copy_url_button": "Kopier lenke",
"error_boundary_title": "Oida. Ntfy krasjet.",
"publish_dialog_message_placeholder": "Skriv en melding her",
"publish_dialog_button_cancel": "Avbryt",
"prefs_notifications_min_priority_title": "Minimumsprioritet",
"prefs_notifications_delete_after_title": "Slett merknader",
"prefs_notifications_delete_after_never": "Aldri",
"publish_dialog_email_label": "E-post",
"publish_dialog_button_send": "Send",
"prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke",
"prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned",
"priority_min": "min.",
"subscribe_dialog_login_button_back": "Tilbake",
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
"prefs_users_dialog_button_cancel": "Avbryt",
"prefs_users_dialog_button_add": "Legg til",
"publish_dialog_chip_attach_url_label": "Legg ved fil per nettadresse",
"publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi",
"prefs_notifications_sound_description_none": "Merknader er lydløse når de mottas",
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler",
"prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere"
}

View File

@@ -0,0 +1,7 @@
{
"action_bar_settings": "Instellingen",
"action_bar_send_test_notification": "Stuur testmelding",
"action_bar_clear_notifications": "Alle meldingen wissen",
"message_bar_type_message": "Typ hier een bericht",
"action_bar_unsubscribe": "Afmelden"
}

View File

@@ -0,0 +1,38 @@
{
"notifications_attachment_open_button": "Abrir anexo",
"action_bar_clear_notifications": "Limpar todas as notificações",
"action_bar_unsubscribe": "Desinscrever",
"message_bar_type_message": "Escreva uma mensagem aqui",
"message_bar_error_publishing": "Erro ao publicar notificação",
"nav_button_all_notifications": "Todas notificações",
"nav_button_settings": "Configurações",
"nav_button_subscribe": "Inscrever no tópico",
"alert_grant_title": "Notificações estão desativadas",
"alert_grant_description": "Conceder ao navegador permissão para mostrar notificações.",
"alert_grant_button": "Conceder agora",
"alert_not_supported_title": "Notificações não são suportadas",
"alert_not_supported_description": "Notificações não são suportadas pelo seu navagador.",
"notifications_copied_to_clipboard": "Copiado para a área de transferência",
"notifications_tags": "Etiquetas",
"notifications_attachment_copy_url_title": "Copiar URL do anexo para a área de transferência",
"notifications_click_copy_url_title": "Copiar URL do link para a área de transferência",
"notifications_click_copy_url_button": "Copiar link",
"notifications_click_open_title": "Ir para {{url}}",
"notifications_click_open_button": "Abrir link",
"notifications_none_for_topic_title": "Você ainda não recebeu nenhuma notificação para esse tópico.",
"notifications_none_for_topic_description": "Para enviar notificações para esse tópico, basta usar os métodos PUT ou POST na URL do tópico.",
"notifications_none_for_any_title": "Você ainda não recebeu nenhuma notificação.",
"notifications_none_for_any_description": "Para enviar notificações a um tópico, basta usar os métodos PUT ou POST para o URL do tópico. Aqui um exemplo usando um dos seus tópicos.",
"notifications_no_subscriptions_title": "Parece que você não tem nenhuma inscrição ainda.",
"notifications_no_subscriptions_description": "Clique no link \"{{linktext}}\" para criar ou inscrever em um tópico. Depois disso, poderá enviar mensagens via PUT ou POST e você receberá notificações aqui.",
"action_bar_settings": "Configurações",
"action_bar_send_test_notification": "Enviar notificação de teste",
"nav_button_documentation": "Documentação",
"nav_button_publish_message": "Publicar notificação",
"nav_topics_title": "Tópicos inscritos",
"notifications_attachment_open_title": "Ir para {{url}}",
"notifications_attachment_link_expires": "link expira em {{date}}",
"notifications_attachment_copy_url_button": "Copiar URL",
"notifications_attachment_link_expired": "link para transferência expirado",
"notifications_example": "Exemplo"
}

View File

@@ -0,0 +1,154 @@
{
"publish_dialog_priority_min": "Мин. приоритет",
"action_bar_settings": "Настройки",
"action_bar_send_test_notification": "Отправить тестовое уведомление",
"action_bar_clear_notifications": "Удалить все уведомления",
"action_bar_unsubscribe": "Отписаться",
"message_bar_type_message": "Введите сообщение здесь",
"notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто отправьте PUT или POST на URL-адрес этой темы.",
"notifications_none_for_any_description": "Чтобы отправить уведомления на тему, просто отправьте PUT или POST на URL-адрес темы. Вот пример используя одну из ваших тем.",
"notifications_no_subscriptions_title": "Похоже у вас ещё нет подписок.",
"alert_grant_description": "Разрешите браузеру показывать уведомления.",
"notifications_no_subscriptions_description": "Нажмите \"{{linktext}}\" ссылку, чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST, и вы будете получать здесь уведомления.",
"notifications_example": "Пример",
"notifications_more_details": "Дополнительную информацию найдёте на <websiteLink>сайте</websiteLink> или в <docsLink>документации</docsLink>.",
"notifications_loading": "Загружаются уведомления …",
"publish_dialog_title_topic": "Опубликовать в {{topic}}",
"publish_dialog_title_no_topic": "Опубликовать уведомление",
"publish_dialog_progress_uploading": "Загружается …",
"publish_dialog_progress_uploading_detail": "Загружается {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Уведомление опубликовано",
"publish_dialog_attachment_limits_file_and_quota_reached": "превышает {{fileSizeLimit}} размер файла, {{remainingBytes}} осталось",
"publish_dialog_attachment_limits_file_reached": "превышает {{fileSizeLimit}} размер файла",
"publish_dialog_attachment_limits_quota_reached": "превышает квоту, {{remainingBytes}} осталось",
"publish_dialog_priority_low": "Низкий приоритет",
"publish_dialog_priority_default": "Приоритет по умолчанию",
"publish_dialog_priority_high": "Высокий приоритет",
"publish_dialog_priority_max": "Макс. приоритет",
"publish_dialog_base_url_label": "URL-адрес сервиса",
"publish_dialog_base_url_placeholder": "URL-адрес сервиса, например https://example.com",
"publish_dialog_topic_label": "Название темы",
"publish_dialog_topic_placeholder": "Название темы, например phil_alerts",
"publish_dialog_title_label": "Заголовок",
"publish_dialog_title_placeholder": "Заголовок уведомления, например Disk space alert",
"publish_dialog_message_label": "Сообщение",
"publish_dialog_message_placeholder": "Текст сообщения",
"publish_dialog_tags_label": "Тэги",
"publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например warning, srv1-backup",
"publish_dialog_priority_label": "Приоритет",
"publish_dialog_click_label": "Нажмите на URL-адрес",
"publish_dialog_click_placeholder": "URL-адрес который откроется когда будет нажато уведомление",
"publish_dialog_email_label": "Эл. почта",
"message_bar_error_publishing": "Ошибка отправки уведомления",
"alert_not_supported_title": "Уведомления не поддерживаются",
"alert_not_supported_description": "Уведомления не поддерживаются вашим браузером.",
"notifications_copied_to_clipboard": "Скопировано в буфер обмена",
"notifications_attachment_open_button": "Открыть вложение",
"notifications_none_for_topic_title": "Вы ещё не получали уведомления для этой темы.",
"nav_topics_title": "Подписки на темы",
"nav_button_all_notifications": "Все уведомления",
"nav_button_settings": "Настройки",
"nav_button_documentation": "Документация",
"nav_button_publish_message": "Опубликовать уведомление",
"nav_button_subscribe": "Подписаться на тему",
"alert_grant_button": "Разрешить",
"notifications_attachment_copy_url_button": "Скопировать URL-адрес",
"notifications_attachment_open_title": "Перейти на {{url}}",
"notifications_attachment_link_expired": "срок действия ссылки для скачивания истёк",
"notifications_click_copy_url_button": "Скопировать ссылку",
"notifications_none_for_any_title": "Вы ещё не получали никаких уведомлений.",
"alert_grant_title": "Уведомления отключены",
"notifications_attachment_copy_url_title": "Скопировать URL-адрес вложения",
"notifications_actions_open_url_title": "Перейти на {{url}}",
"notifications_tags": "Тэги",
"notifications_attachment_link_expires": "срок действия ссылки истекает {{date}}",
"notifications_click_copy_url_title": "Скопировать URL-адрес ссылки",
"notifications_click_open_button": "Открыть ссылку",
"subscribe_dialog_subscribe_title": "Подписаться на тему",
"publish_dialog_button_cancel": "Отмена",
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы можете размещать/отправлять уведомления.",
"prefs_users_description": "Добавляйте/удаляйте пользователей для защищенных тем. Обратите внимание, что имя пользователя и пароль хранятся в локальном хранилище браузера.",
"error_boundary_description": "Этого, очевидно, не должно происходить. Очень сожалею об этом. <br/>Если у вас есть минутка, пожалуйста <githubLink>сообщить об этом на GitHub</githubLink>, или сообщите нам через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
"publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com",
"publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_label": "Имя файла",
"publish_dialog_delay_label": "Задержка",
"publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
"publish_dialog_chip_click_label": "Адрес",
"publish_dialog_chip_email_label": "Переслать на электронную почту",
"publish_dialog_chip_attach_url_label": "Прикрепить файл по URL",
"publish_dialog_chip_attach_file_label": "Прикрепить локальный файл",
"publish_dialog_chip_delay_label": "Задержка отправки",
"publish_dialog_chip_topic_label": "Изменить тему",
"publish_dialog_details_examples_description": "Примеры и подробное описание всех функций см. в e <docsLink>документации</docsLink>.",
"publish_dialog_attach_label": "URL-адрес вложения",
"publish_dialog_filename_placeholder": "Имя файла вложения",
"publish_dialog_other_features": "Другие возможности:",
"publish_dialog_button_cancel_sending": "Отменить отправку",
"publish_dialog_button_send": "Отправить",
"publish_dialog_checkbox_publish_another": "Опубликовать еще",
"publish_dialog_attached_file_title": "Прикрепленный файл:",
"publish_dialog_attached_file_filename_placeholder": "Имя прикреплённого файла",
"emoji_picker_search_placeholder": "Поиск эмодзи",
"subscribe_dialog_subscribe_topic_placeholder": "Название темы. Например, phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Использовать другой сервер",
"subscribe_dialog_subscribe_button_cancel": "Отмена",
"subscribe_dialog_subscribe_button_subscribe": "Подписаться",
"subscribe_dialog_login_title": "Требуется авторизация",
"subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.",
"subscribe_dialog_login_username_label": "Имя пользователя. Например, phil",
"subscribe_dialog_login_password_label": "Пароль",
"subscribe_dialog_login_button_back": "Назад",
"subscribe_dialog_login_button_login": "Войти",
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
"subscribe_dialog_error_user_anonymous": "аноним",
"prefs_notifications_title": "Уведомления",
"prefs_notifications_sound_title": "Звук уведомления",
"prefs_notifications_sound_description_none": "Уведомления не воспроизводят никаких звуков при получении",
"prefs_notifications_sound_no_sound": "Без звука",
"prefs_notifications_min_priority_title": "Минимальный приоритет",
"prefs_notifications_min_priority_description_any": "Показать все уведомления, независимо от приоритета",
"prefs_notifications_min_priority_description_x_or_higher": "Показывать уведомления, если приоритет {{number}} ({{name}}) или выше",
"prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимум)",
"prefs_notifications_min_priority_any": "Любой приоритет",
"prefs_notifications_min_priority_low_and_higher": "Низкий и высокий приоритет",
"prefs_notifications_min_priority_max_only": "Только максимальный приоритет",
"prefs_notifications_delete_after_title": "Удалить уведомления",
"prefs_notifications_delete_after_never": "Никогда",
"prefs_notifications_delete_after_three_hours": "Через три часа",
"prefs_notifications_sound_description_some": "Уведомления воспроизводят звук {{sound}}",
"prefs_notifications_min_priority_default_and_higher": "Приоритет по умолчанию и высокий",
"prefs_notifications_delete_after_one_day": "Через день",
"prefs_notifications_delete_after_one_week": "Через неделю",
"prefs_notifications_delete_after_one_month": "Через месяц",
"prefs_notifications_delete_after_never_description": "Уведомления никогда не удаляются автоматически",
"prefs_notifications_delete_after_three_hours_description": "Уведомления автоматически удаляются через три часа",
"prefs_notifications_delete_after_one_day_description": "Уведомления автоматически удаляются через один день",
"prefs_notifications_delete_after_one_week_description": "Уведомления автоматически удаляются через неделю",
"prefs_notifications_delete_after_one_month_description": "Уведомления автоматически удаляются через месяц",
"prefs_users_title": "Управление пользователями",
"prefs_users_add_button": "Добавить пользователя",
"prefs_users_table_user_header": "Пользователь",
"prefs_users_table_base_url_header": "URL службы",
"prefs_users_dialog_title_add": "Добавить пользователя",
"prefs_users_dialog_title_edit": "Редактировать пользователя",
"prefs_users_dialog_base_url_label": "URL-адрес службы. Например, https://ntfy.sh",
"prefs_users_dialog_username_label": "Имя пользователя. Например, phil",
"prefs_users_dialog_password_label": "Пароль",
"prefs_users_dialog_button_cancel": "Отмена",
"prefs_users_dialog_button_add": "Добавить",
"prefs_users_dialog_button_save": "Сохранить",
"prefs_appearance_title": "Внешний вид",
"prefs_appearance_language_title": "Язык",
"priority_min": "минимум",
"priority_low": "низкий",
"priority_default": "по умолчанию",
"priority_high": "высокий",
"priority_max": "максимальный",
"error_boundary_title": "О нет, Ntfy сломался",
"error_boundary_button_copy_stack_trace": "Копирование трассировки стека",
"error_boundary_stack_trace": "Трассировка стека",
"error_boundary_gathering_info": "Соберите больше информации …",
"publish_dialog_drop_file_here": "Перетащите файл юда",
"prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше"
}

View File

@@ -0,0 +1,156 @@
{
"nav_button_subscribe": "Konuya abone ol",
"nav_button_settings": "Ayarlar",
"action_bar_send_test_notification": "Test bildirimi gönder",
"message_bar_type_message": "Buraya bir mesaj yazın",
"action_bar_clear_notifications": "Tüm bildirimleri temizle",
"action_bar_unsubscribe": "Abonelikten çık",
"action_bar_settings": "Ayarlar",
"message_bar_error_publishing": "Bildirim yayınlanırken hata oluştu",
"nav_topics_title": "Abone olunan konular",
"nav_button_all_notifications": "Tüm bildirimler",
"publish_dialog_tags_placeholder": "Virgülle ayrılmış etiket listesi, örn. uyarı, srv1-yedekleme",
"publish_dialog_priority_label": "Öncelik",
"publish_dialog_click_label": "Tıklama URL'si",
"publish_dialog_click_placeholder": "Bildirim tıklandığında açılan URL",
"publish_dialog_email_label": "E-posta adresi",
"publish_dialog_email_placeholder": "Bildirimin iletileceği adres, örn. phil@example.com",
"publish_dialog_attach_label": "Ek URL'si",
"publish_dialog_filename_label": "Dosya adı",
"publish_dialog_filename_placeholder": "Ek dosya adı",
"publish_dialog_delay_label": "Gecikme",
"publish_dialog_button_cancel": "İptal",
"publish_dialog_button_send": "Gönder",
"publish_dialog_checkbox_publish_another": "Başka bir tane yayınla",
"publish_dialog_attached_file_title": "Ekli dosya:",
"publish_dialog_attached_file_filename_placeholder": "Ek dosya adı",
"subscribe_dialog_subscribe_title": "Konuya abone ol",
"subscribe_dialog_subscribe_description": "Konular parola korumalı olmayabilir, bu nedenle tahmin edilmesi kolay olmayan bir ad seçin. Abone olduktan sonra PUT/POST bildirimleri yapabilirsiniz.",
"subscribe_dialog_subscribe_topic_placeholder": "Konu adı, örn. benim_uyarilarim",
"subscribe_dialog_subscribe_use_another_label": "Başka bir sunucu kullan",
"subscribe_dialog_subscribe_button_cancel": "İptal",
"subscribe_dialog_subscribe_button_subscribe": "Abone ol",
"subscribe_dialog_login_title": "Oturum açma gerekli",
"subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.",
"subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil",
"subscribe_dialog_login_password_label": "Parola",
"subscribe_dialog_login_button_back": "Geri",
"subscribe_dialog_login_button_login": "Oturum aç",
"subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil",
"subscribe_dialog_error_user_anonymous": "anonim",
"prefs_notifications_title": "Bildirimler",
"prefs_notifications_sound_title": "Bildirim sesi",
"prefs_notifications_sound_no_sound": "Ses yok",
"prefs_notifications_min_priority_title": "En düşük öncelik",
"prefs_notifications_min_priority_any": "Herhangi bir öncelik",
"publish_dialog_topic_placeholder": "Konu adı, örn. benim_uyarilarim",
"alert_grant_button": "Şimdi ver",
"alert_not_supported_title": "Bildirimler desteklenmiyor",
"notifications_attachment_link_expires": "bağlantının süresi {{date}} tarihinde doluyor",
"notifications_click_copy_url_title": "Bağlantı URL'sini panoya kopyala",
"notifications_loading": "Bildirimler yükleniyor…",
"publish_dialog_progress_uploading": "Karşıya yükleniyor…",
"publish_dialog_attachment_limits_file_reached": "{{fileSizeLimit}} dosya sınırınııyor",
"publish_dialog_priority_default": "Öntanımlı öncelik",
"publish_dialog_chip_click_label": "Tıklama URL'si",
"publish_dialog_attach_placeholder": "URL ile dosya ekle, örn. https://f-droid.org/F-Droid.apk",
"prefs_notifications_delete_after_never": "Hiçbir zaman",
"notifications_attachment_copy_url_button": "URL'yi kopyala",
"notifications_attachment_open_button": "Eki aç",
"nav_button_documentation": "Belgelendirme",
"nav_button_publish_message": "Bildirim yayınla",
"alert_grant_title": "Bildirimler devre dışı",
"alert_grant_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",
"notifications_attachment_open_title": "{{url}} adresine git",
"notifications_none_for_topic_title": "Bu konu için henüz herhangi bir bildirim almadınız.",
"notifications_none_for_topic_description": "Bu konuya bildirim göndermek için konu URL'sine PUT veya POST göndermeniz yeterlidir.",
"notifications_none_for_any_title": "Herhangi bir bildirim almadınız.",
"notifications_attachment_link_expired": "indirme bağlantısının süresi doldu",
"notifications_click_copy_url_button": "Bağlantıyı kopyala",
"notifications_actions_open_url_title": "{{url}} adresine git",
"notifications_click_open_button": "Bağlantıyı aç",
"notifications_no_subscriptions_description": "Bir konu oluşturmak veya bir konuya abone olmak için \"{{linktext}}\" bağlantısına tıklayın. Bundan sonra PUT veya POST yoluyla mesaj gönderebilirsiniz ve buradan bildirimler alırsınız.",
"notifications_example": "Örnek",
"notifications_more_details": "Daha fazla bilgi için <websiteLink>web sitesine</websiteLink> veya <docsLink>belgelendirmeye</docsLink> bakın.",
"publish_dialog_chip_attach_url_label": "URL ile dosya ekle",
"prefs_notifications_min_priority_default_and_higher": "Öntanımlı öncelik ve üstü",
"prefs_notifications_delete_after_three_hours": "Üç saat sonra",
"notifications_none_for_any_description": "Bir konuya bildirim göndermek için konu URL'sine PUT veya POST göndermeniz yeterlidir. İşte konularınızdan birini kullanan bir örnek.",
"notifications_no_subscriptions_title": "Henüz aboneliğiniz yok gibi görünüyor.",
"publish_dialog_title_topic": "{{topic}} konusuna yayınla",
"publish_dialog_title_no_topic": "Bildirim yayınla",
"publish_dialog_progress_uploading_detail": "Karşıya yükleniyor: {{loaded}}/{{total}} ({{percent}}%)…",
"publish_dialog_attachment_limits_file_and_quota_reached": "{{fileSizeLimit}} dosya sınırını ve kotasınııyor, kalan {{remainingBytes}}",
"publish_dialog_priority_min": "En düşük öncelik",
"publish_dialog_priority_low": "Düşük öncelik",
"publish_dialog_base_url_label": "Hizmet URL'si",
"publish_dialog_attachment_limits_quota_reached": "kotayııyor, kalan {{remainingBytes}}",
"publish_dialog_message_published": "Bildirim yayınlandı",
"publish_dialog_title_label": "Başlık",
"publish_dialog_priority_high": "Yüksek öncelik",
"publish_dialog_priority_max": "En yüksek öncelik",
"publish_dialog_message_label": "Mesaj",
"publish_dialog_other_features": "Diğer özellikler:",
"publish_dialog_chip_email_label": "E-posta adresine ilet",
"publish_dialog_topic_label": "Konu adı",
"publish_dialog_base_url_placeholder": "Hizmet URL'si, örn. https://example.com",
"publish_dialog_title_placeholder": "Bildirim başlığı, örn. Disk alanı uyarısı",
"publish_dialog_message_placeholder": "Buraya bir mesaj yazın",
"publish_dialog_tags_label": "Etiketler",
"publish_dialog_delay_placeholder": "Teslimat gecikmesi, örn. {{unixTimestamp}}, {{relativeTime}} veya \"{{naturalLanguage}}\"",
"publish_dialog_chip_attach_file_label": "Yerel dosya ekle",
"publish_dialog_chip_delay_label": "Teslimat gecikmesi",
"publish_dialog_chip_topic_label": "Konuyu değiştir",
"publish_dialog_button_cancel_sending": "Göndermeyi iptal et",
"prefs_notifications_delete_after_one_week": "Bir hafta sonra",
"prefs_notifications_delete_after_one_month": "Bir ay sonra",
"publish_dialog_details_examples_description": "Örnekler ve tüm gönderme özelliklerinin ayrıntılııklaması için lütfen <docsLink>belgelendirmeye</docsLink> bakın.",
"emoji_picker_search_placeholder": "Emoji ara",
"prefs_notifications_delete_after_title": "Bildirimleri sil",
"prefs_notifications_delete_after_one_day": "Bir gün sonra",
"publish_dialog_drop_file_here": "Dosyayı buraya bırakın",
"prefs_notifications_min_priority_low_and_higher": "Düşük öncelik ve üstü",
"prefs_notifications_min_priority_high_and_higher": "Yüksek öncelik ve üstü",
"prefs_notifications_min_priority_max_only": "Yalnızca en yüksek öncelik",
"prefs_users_title": "Kullanıcıları yönet",
"prefs_users_dialog_title_edit": "Kullanıcıyı düzenle",
"prefs_users_dialog_base_url_label": "Hizmet URL'si, örn. https://ntfy.sh",
"prefs_users_description": "Burada korunan konularınız için kullanıcı ekleyin/kaldırın. Lütfen kullanıcı adı ve parolanın tarayıcının yerel deposunda saklandığını unutmayın.",
"prefs_users_add_button": "Kullanıcı ekle",
"prefs_users_table_base_url_header": "Hizmet URL'si",
"prefs_users_dialog_title_add": "Kullanıcı ekle",
"prefs_users_dialog_username_label": "Kullanıcı adı, örn. phil",
"prefs_users_table_user_header": "Kullanıcı",
"prefs_users_dialog_password_label": "Parola",
"prefs_users_dialog_button_add": "Ekle",
"prefs_users_dialog_button_cancel": "İptal",
"prefs_users_dialog_button_save": "Kaydet",
"prefs_appearance_title": "Görünüm",
"prefs_appearance_language_title": "Dil",
"error_boundary_title": "Olamaz, ntfy çöktü",
"error_boundary_gathering_info": "Daha fazla bilgi topla…",
"error_boundary_description": "Bunun olmaması gerekiyordu. Çok üzgünüm.<br/>Bir dakikanız varsa, lütfen <githubLink>bunu GitHub üzerinden bildirin</githubLink> ya da <discordLink>Discord</discordLink> veya <matrixLink>Matrix</matrixLink> aracılığıyla bize iletin.",
"error_boundary_button_copy_stack_trace": "Yığın izlemeyi kopyala",
"error_boundary_stack_trace": "Yığın izleme",
"prefs_notifications_sound_description_none": "Bildirimler geldiğinde herhangi bir ses çalmaz",
"prefs_notifications_sound_description_some": "Bildirimler geldiğinde {{sound}} sesini çalar",
"prefs_notifications_min_priority_description_any": "Öncelikten bağımsız olarak tüm bildirimleri göster",
"prefs_notifications_min_priority_description_x_or_higher": "Öncelik {{number}} ({{name}}) veya üstüyse bildirimleri göster",
"prefs_notifications_delete_after_one_month_description": "Bildirimler bir ay sonra otomatik olarak silinir",
"prefs_notifications_delete_after_three_hours_description": "Bildirimler üç saat sonra otomatik olarak silinir",
"prefs_notifications_delete_after_one_week_description": "Bildirimler bir hafta sonra otomatik olarak silinir",
"priority_min": "en düşük",
"priority_low": "düşük",
"priority_max": "en yüksek",
"prefs_notifications_delete_after_one_day_description": "Bildirimler bir gün sonra otomatik olarak silinir",
"priority_default": "öntanımlı",
"prefs_notifications_min_priority_description_max": "Öncelik 5 (en fazla) ise bildirimleri göster",
"prefs_notifications_delete_after_never_description": "Bildirimler asla otomatik olarak silinmez",
"priority_high": "yüksek",
"notifications_actions_not_supported": "Eylem, web uygulamasında desteklenmiyor",
"notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder"
}

View File

@@ -1,11 +1,13 @@
import {
basicAuth,
encodeBase64,
fetchLinesIterator,
maybeWithBasicAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince
topicUrlJsonPollWithSince, userStatsUrl
} from "./utils";
import userManager from "./UserManager";
@@ -26,25 +28,78 @@ class Api {
return messages;
}
async publish(baseUrl, topic, message, title, priority, tags) {
async publish(baseUrl, topic, message, options) {
const user = await userManager.get(baseUrl);
const url = topicUrl(baseUrl, topic);
console.log(`[Api] Publishing message to ${url}`);
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
const headers = {};
if (title) {
headers["X-Title"] = title;
}
if (priority !== 3) {
headers["X-Priority"] = `${priority}`;
}
if (tags.length > 0) {
headers["X-Tags"] = tags.join(",");
}
await fetch(url, {
const body = {
topic: topic,
message: message,
...options
};
const response = await fetch(baseUrl, {
method: 'PUT',
body: message,
body: JSON.stringify(body),
headers: maybeWithBasicAuth(headers, user)
});
if (response.status < 200 || response.status > 299) {
throw new Error(`Unexpected response: ${response.status}`);
}
return response;
}
/**
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
*
* Firefox XHR bug:
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
* correct headers are clearly set. It's quite the odd behavior.
*
* There is an example, and the bug report here:
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
*/
publishXHR(url, body, headers, onProgress) {
console.log(`[Api] Publishing message to ${url}`);
const xhr = new XMLHttpRequest();
const send = new Promise(function (resolve, reject) {
xhr.open("PUT", url);
if (body.type) {
xhr.overrideMimeType(body.type);
}
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
xhr.upload.addEventListener("progress", onProgress);
xhr.addEventListener('readystatechange', (ev) => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
resolve(xhr.response);
} else if (xhr.readyState === 4) {
// Firefox bug; see description above!
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
let errorText;
try {
const error = JSON.parse(xhr.responseText);
if (error.code && error.error) {
errorText = `Error ${error.code}: ${error.error}`;
}
} catch (e) {
// Nothing
}
xhr.abort();
reject(errorText ?? "An error occurred");
}
})
xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
}
return send;
}
async auth(baseUrl, topic, user) {
@@ -62,6 +117,18 @@ class Api {
}
throw new Error(`Unexpected server response ${response.status}`);
}
async userStats(baseUrl) {
const url = userStatsUrl(baseUrl);
console.log(`[Api] Fetching user stats ${url}`);
const response = await fetch(url);
if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const stats = await response.json();
console.log(`[Api] Stats`, stats);
return stats;
}
}
const api = new Api();

View File

@@ -56,6 +56,4 @@ class Poller {
}
const poller = new Poller();
poller.startWorker();
export default poller;

View File

@@ -1,7 +1,7 @@
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
const delayMillis = 15000; // 15 seconds
const delayMillis = 25000; // 25 seconds
const intervalMillis = 1800000; // 30 minutes
class Pruner {
@@ -35,6 +35,4 @@ class Pruner {
}
const pruner = new Pruner();
pruner.startWorker();
export default pruner;

View File

@@ -92,6 +92,19 @@ class SubscriptionManager {
});
}
async updateNotification(notification) {
const exists = await db.notifications.get(notification.id);
if (!exists) {
return false;
}
try {
await db.notifications.put({ ...notification });
} catch (e) {
console.error(`[SubscriptionManager] Error updating notification`, e);
}
return true;
}
async deleteNotification(notificationId) {
await db.notifications.delete(notificationId);
}
@@ -102,6 +115,12 @@ class SubscriptionManager {
.delete();
}
async markNotificationRead(notificationId) {
await db.notifications
.where({id: notificationId})
.modify({new: 0});
}
async markNotificationsRead(subscriptionId) {
await db.notifications
.where({subscriptionId: subscriptionId, new: 1})

File diff suppressed because one or more lines are too long

View File

@@ -18,12 +18,13 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
export const validUrl = (url) => {
return url.match(/^https?:\/\//);
return url.match(/^https?:\/\/.+/);
}
export const validTopic = (topic) => {
@@ -104,6 +105,18 @@ export const encodeBase64Url = (s) => {
return Base64.encodeURI(s);
}
export const maybeAppendActionErrors = (message, notification) => {
const actionErrors = (notification.actions ?? [])
.map(action => action.error)
.filter(action => !!action)
.join("\n")
if (actionErrors.length === 0) {
return message;
} else {
return `${message}\n\n${actionErrors}`;
}
}
export const shuffle = (arr) => {
let j, x;
for (let index = arr.length - 1; index > 0; index--) {
@@ -115,6 +128,13 @@ export const shuffle = (arr) => {
return arr;
}
export const splitNoEmpty = (s, delimiter) => {
return s
.split(delimiter)
.map(x => x.trim())
.filter(x => x !== "");
}
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => {
let hash = 0;
@@ -145,17 +165,38 @@ export const openUrl = (url) => {
};
export const sounds = {
"beep": beep,
"juntos": juntos,
"pristine": pristine,
"ding": ding,
"dadum": dadum,
"pop": pop,
"pop-swoosh": popSwoosh
"ding": {
file: ding,
label: "Ding"
},
"juntos": {
file: juntos,
label: "Juntos"
},
"pristine": {
file: pristine,
label: "Pristine"
},
"dadum": {
file: dadum,
label: "Dadum"
},
"pop": {
file: pop,
label: "Pop"
},
"pop-swoosh": {
file: popSwoosh,
label: "Pop swoosh"
},
"beep": {
file: beep,
label: "Beep"
}
};
export const playSound = async (sound) => {
const audio = new Audio(sounds[sound]);
export const playSound = async (id) => {
const audio = new Audio(sounds[id].file);
return audio.play();
};

View File

@@ -22,14 +22,17 @@ import api from "../app/Api";
import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg";
import {useTranslation} from "react-i18next";
import {Portal, Snackbar} from "@mui/material";
const ActionBar = (props) => {
const { t } = useTranslation();
const location = useLocation();
let title = "ntfy";
if (props.selected) {
title = topicShortUrl(props.selected.baseUrl, props.selected.topic);
} else if (location.pathname === "/settings") {
title = "Settings";
title = t("action_bar_settings");
}
return (
<AppBar position="fixed" sx={{
@@ -41,16 +44,22 @@ const ActionBar = (props) => {
<IconButton
color="inherit"
edge="start"
aria-label={t("action_bar_show_menu")}
onClick={props.onMobileDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Box component="img" src={logo} sx={{
display: { xs: 'none', sm: 'block' },
marginRight: '10px',
height: '28px'
}}/>
<Box
component="img"
src={logo}
alt={t("action_bar_logo_alt")}
sx={{
display: { xs: 'none', sm: 'block' },
marginRight: '10px',
height: '28px'
}}
/>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{title}
</Typography>
@@ -66,8 +75,10 @@ const ActionBar = (props) => {
// Originally from https://mui.com/components/menus/#MenuListComposition.js
const SettingsIcons = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const anchorRef = useRef(null);
const subscription = props.subscription;
@@ -105,7 +116,7 @@ const SettingsIcons = (props) => {
}
};
const handleSendTestMessage = () => {
const handleSendTestMessage = async () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
@@ -121,18 +132,30 @@ const SettingsIcons = (props) => {
"Titles are optional, did you know that?",
"ntfy is open source, and will always be free. Cool, right?",
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!"
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days"
])[0];
const nowSeconds = Math.round(Date.now()/1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(Date.now())} right now. Is that early or late?`,
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(Date.now())} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
])[0];
api.publish(baseUrl, topic, message, title, priority, tags);
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags
});
} catch (e) {
console.log(`[ActionBar] Error publishing message`, e);
setSnackOpen(true);
}
setOpen(false);
}
@@ -156,10 +179,10 @@ const SettingsIcons = (props) => {
return (
<>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}}>
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}>
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
</IconButton>
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen}>
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
<MoreVertIcon/>
</IconButton>
<Popper
@@ -178,15 +201,23 @@ const SettingsIcons = (props) => {
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
<MenuItem onClick={handleSendTestMessage}>Send test notification</MenuItem>
<MenuItem onClick={handleClearAll}>Clear all notifications</MenuItem>
<MenuItem onClick={handleUnsubscribe}>Unsubscribe</MenuItem>
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("message_bar_error_publishing")}
/>
</Portal>
</>
);
};

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { Suspense } from "react";
import {useEffect, useState} from 'react';
import Box from '@mui/material/Box';
import {ThemeProvider} from '@mui/material/styles';
@@ -17,29 +18,34 @@ import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from
import {expandUrl} from "../app/utils";
import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes";
import {useAutoSubscribe, useConnectionListeners, useLocalStorageMigration} from "./hooks";
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
import PublishDialog from "./PublishDialog";
import Messaging from "./Messaging";
import "./i18n"; // Translations!
import {Backdrop, CircularProgress} from "@mui/material";
// TODO add drag and drop
// TODO races when two tabs are open
// TODO investigate service workers
const App = () => {
return (
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline/>
<ErrorBoundary>
<Routes>
<Route element={<Layout/>}>
<Route path={routes.root} element={<AllSubscriptions/>}/>
<Route path={routes.settings} element={<Preferences/>}/>
<Route path={routes.subscription} element={<SingleSubscription/>}/>
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
</Route>
</Routes>
</ErrorBoundary>
</ThemeProvider>
</BrowserRouter>
<Suspense fallback={<Loader />}>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline/>
<ErrorBoundary>
<Routes>
<Route element={<Layout/>}>
<Route path={routes.root} element={<AllSubscriptions/>}/>
<Route path={routes.settings} element={<Preferences/>}/>
<Route path={routes.subscription} element={<SingleSubscription/>}/>
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
</Route>
</Routes>
</ErrorBoundary>
</ThemeProvider>
</BrowserRouter>
</Suspense>
);
}
@@ -58,6 +64,7 @@ const Layout = () => {
const params = useParams();
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
@@ -67,7 +74,7 @@ const Layout = () => {
});
useConnectionListeners(subscriptions, users);
useLocalStorageMigration();
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
@@ -84,11 +91,17 @@ const Layout = () => {
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar/>
<Outlet context={{ subscriptions, selected }}/>
</Main>
<Messaging
selected={selected}
dialogOpenMode={sendDialogOpenMode}
onDialogOpenModeChange={setSendDialogOpenMode}
/>
</Box>
);
}
@@ -114,6 +127,18 @@ const Main = (props) => {
);
};
const Loader = () => (
<Backdrop
open={true}
sx={{
zIndex: 100000,
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
<CircularProgress color="success" disableShrink />
</Backdrop>
);
const updateTitle = (newNotificationsCount) => {
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
}

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import Box from "@mui/material/Box";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg";
import {useTranslation} from "react-i18next";
const AttachmentIcon = (props) => {
const { t } = useTranslation();
const type = props.type;
let imageFile, imageLabel;
if (!type) {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_image");
} else if (type.startsWith('image/')) {
imageFile = fileImage;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('video/')) {
imageFile = fileVideo;
imageLabel = t("notifications_attachment_file_video");
} else if (type.startsWith('audio/')) {
imageFile = fileAudio;
imageLabel = t("notifications_attachment_file_audio");
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
imageLabel = t("notifications_attachment_file_app");
} else {
imageFile = fileDocument;
imageLabel = t("notifications_attachment_file_document");
}
return (
<Box
component="img"
src={imageFile}
alt={imageLabel}
loading="lazy"
sx={{
width: '28px',
height: '28px'
}}
/>
);
}
export default AttachmentIcon;

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import Box from "@mui/material/Box";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
const DialogFooter = (props) => {
return (
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingBottom: '8px',
}}>
<DialogContentText
component="div"
aria-live="polite"
sx={{
margin: '0px',
paddingTop: '12px',
paddingBottom: '4px'
}}
>
{props.status}
</DialogContentText>
<DialogActions sx={{paddingRight: 2}}>
{props.children}
</DialogActions>
</Box>
);
};
export default DialogFooter;

View File

@@ -0,0 +1,179 @@
import * as React from 'react';
import {useRef, useState} from 'react';
import Typography from '@mui/material/Typography';
import {rawEmojis} from '../app/emojis';
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Close} from "@mui/icons-material";
import Popper from "@mui/material/Popper";
import {splitNoEmpty} from "../app/utils";
import {useTranslation} from "react-i18next";
// Create emoji list by category and create a search base (string with all search words)
//
// This also filters emojis that are not supported by Desktop Chrome.
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
const emojisByCategory = {};
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach(emoji => {
if (!emojisByCategory[emoji.category]) {
emojisByCategory[emoji.category] = [];
}
try {
const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase);
}
} catch (e) {
// Nothing. Ignore.
}
});
const EmojiPicker = (props) => {
const { t } = useTranslation();
const open = Boolean(props.anchorEl);
const [search, setSearch] = useState("");
const searchRef = useRef(null);
const searchFields = splitNoEmpty(search.toLowerCase(), " ");
const handleSearchClear = () => {
setSearch("");
searchRef.current?.focus();
};
return (
<Popper
open={open}
anchorEl={props.anchorEl}
placement="bottom-start"
sx={{ zIndex: 10005 }}
transition
>
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}>
<Box sx={{
boxShadow: 3,
padding: 2,
paddingRight: 0,
paddingBottom: 1,
width: "380px",
maxHeight: "300px",
backgroundColor: 'background.paper',
overflowY: "scroll"
}}>
<TextField
inputRef={searchRef}
margin="dense"
size="small"
placeholder={t("emoji_picker_search_placeholder")}
value={search}
onChange={ev => setSearch(ev.target.value)}
type="text"
variant="standard"
fullWidth
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
inputProps={{
role: "searchbox",
"aria-label": t("emoji_picker_search_placeholder")
}}
InputProps={{
endAdornment:
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
<Close/>
</IconButton>
</InputAdornment>
}}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}>
{Object.keys(emojisByCategory).map(category =>
<Category
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
)}
</Box>
</Box>
</Fade>
</ClickAwayListener>
)}
</Popper>
);
};
const Category = (props) => {
const showTitle = props.search.length === 0;
return (
<>
{showTitle &&
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
{props.title}
</Typography>
}
{props.emojis.map(emoji =>
<Emoji
key={emoji.aliases[0]}
emoji={emoji}
search={props.search}
onClick={() => props.onPick(emoji.aliases[0])}
/>
)}
</>
);
};
const Emoji = (props) => {
const emoji = props.emoji;
const matches = emojiMatches(emoji, props.search);
const title = `${emoji.description} (${emoji.aliases[0]})`;
return (
<EmojiDiv
onClick={props.onClick}
title={title}
aria-label={title}
style={{ display: (matches) ? '' : 'none' }}
>
{props.emoji.emoji}
</EmojiDiv>
);
};
const EmojiDiv = styled("div")({
fontSize: "30px",
width: "30px",
height: "30px",
marginTop: "8px",
marginBottom: "8px",
marginRight: "8px",
lineHeight: "30px",
cursor: "pointer",
opacity: 0.85,
"&:hover": {
opacity: 1
}
});
const emojiMatches = (emoji, words) => {
if (words.length === 0) {
return true;
}
for (const word of words) {
if (emoji.searchBase.indexOf(word) === -1) {
return false;
}
}
return true;
}
export default EmojiPicker;

View File

@@ -1,21 +1,37 @@
import * as React from "react";
import StackTrace from "stacktrace-js";
import {CircularProgress} from "@mui/material";
import {CircularProgress, Link} from "@mui/material";
import Button from "@mui/material/Button";
import {Trans, withTranslation} from "react-i18next";
class ErrorBoundary extends React.Component {
class ErrorBoundaryImpl extends React.Component {
constructor(props) {
super(props);
this.state = {
error: false,
originalStack: null,
niceStack: null
niceStack: null,
unsupportedIndexedDB: false
};
}
componentDidCatch(error, info) {
console.error("[ErrorBoundary] Error caught", error, info);
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
// - https://github.com/dexie/Dexie.js/issues/312
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" ||
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
if (isUnsupportedIndexedDB) {
this.handleUnsupportedIndexedDB();
} else {
this.handleError(error, info);
}
}
handleError(error, info) {
// Immediately render original stack trace
const prettierOriginalStack = info.componentStack
.trim()
@@ -35,6 +51,13 @@ class ErrorBoundary extends React.Component {
});
}
handleUnsupportedIndexedDB() {
this.setState({
error: true,
unsupportedIndexedDB: true
});
}
copyStack() {
let stack = "";
if (this.state.niceStack) {
@@ -46,27 +69,61 @@ class ErrorBoundary extends React.Component {
render() {
if (this.state.error) {
return (
<div style={{margin: '20px'}}>
<h2>Oh no, ntfy crashed 😮</h2>
<p>
This should obviously not happen. Very sorry about this.<br/>
If you have a minute, please <a href="https://github.com/binwiederhier/ntfy/issues">report this on GitHub</a>, or let us
know via <a href="https://discord.gg/cT7ECsZj9w">Discord</a> or <a href="https://matrix.to/#/#ntfy:matrix.org">Matrix</a>.
</p>
<p>
<Button variant="outlined" onClick={() => this.copyStack()}>Copy stack trace</Button>
</p>
<h3>Stack trace</h3>
{this.state.niceStack
? <pre>{this.state.niceStack}</pre>
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> Gather more info ...</>}
<pre>{this.state.originalStack}</pre>
</div>
);
if (this.state.unsupportedIndexedDB) {
return this.renderUnsupportedIndexedDB();
} else {
return this.renderError();
}
}
return this.props.children;
}
renderUnsupportedIndexedDB() {
const { t } = this.props;
return (
<div style={{margin: '20px'}}>
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
<p style={{maxWidth: "600px"}}>
<Trans
i18nKey="error_boundary_unsupported_indexeddb_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
}}
/>
</p>
</div>
);
}
renderError() {
const { t } = this.props;
return (
<div style={{margin: '20px'}}>
<h2>{t("error_boundary_title")} 😮</h2>
<p>
<Trans
i18nKey="error_boundary_description"
components={{
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
}}
/>
</p>
<p>
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
</p>
<h3>{t("error_boundary_stack_trace")}</h3>
{this.state.niceStack
? <pre>{this.state.niceStack}</pre>
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
<pre>{this.state.originalStack}</pre>
</div>
);
}
}
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
export default ErrorBoundary;

View File

@@ -0,0 +1,114 @@
import * as React from 'react';
import {useState} from 'react';
import Navigation from "./Navigation";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import PublishDialog from "./PublishDialog";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {Portal, Snackbar} from "@mui/material";
import {useTranslation} from "react-i18next";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode;
const subscription = props.selected;
const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT);
};
const handleDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1);
};
return (
<>
{subscription && <MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>}
<PublishDialog
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? window.location.origin}
topic={subscription?.topic ?? ""}
message={message}
onClose={handleDialogClose}
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
/>
</>
);
}
const MessageBar = (props) => {
const { t } = useTranslation();
const subscription = props.subscription;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
try {
await api.publish(subscription.baseUrl, subscription.topic, props.message);
} catch (e) {
console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true);
}
props.onMessageChange("");
};
return (
<Paper
elevation={3}
sx={{
display: "flex",
position: 'fixed',
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}>
<KeyboardArrowUpIcon/>
</IconButton>
<TextField
autoFocus
margin="dense"
placeholder={t("message_bar_type_message")}
aria-label={t("message_bar_type_message")}
role="textbox"
type="text"
fullWidth
variant="standard"
value={props.message}
onChange={ev => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSendClick();
}
}}
/>
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
<SendIcon/>
</IconButton>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("message_bar_error_publishing")}
/>
</Portal>
</Paper>
);
};
export default Messaging;

View File

@@ -19,21 +19,27 @@ import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager";
import {ChatBubble, NotificationsOffOutlined} from "@mui/icons-material";
import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
import ArticleIcon from '@mui/icons-material/Article';
import {useTranslation} from "react-i18next";
const navWidth = 280;
const Navigation = (props) => {
const navigationList = <NavList {...props}/>;
return (
<Box component="nav" sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}>
<Box
component="nav"
role="navigation"
sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}
>
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
<Drawer
variant="temporary"
role="menubar"
open={props.mobileDrawerOpen}
onClose={props.onMobileDrawerToggle}
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
@@ -48,6 +54,7 @@ const Navigation = (props) => {
<Drawer
open
variant="permanent"
role="menubar"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
@@ -61,6 +68,7 @@ const Navigation = (props) => {
Navigation.width = navWidth;
const NavList = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
@@ -95,14 +103,14 @@ const NavList = (props) => {
{!showSubscriptionsList &&
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary="All notifications"/>
<ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>}
{showSubscriptionsList &&
<>
<ListSubheader>Subscribed topics</ListSubheader>
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
<ListItemIcon><ChatBubble/></ListItemIcon>
<ListItemText primary="All notifications"/>
<ListItemText primary={t("nav_button_all_notifications")}/>
</ListItemButton>
<SubscriptionList
subscriptions={props.subscriptions}
@@ -112,15 +120,19 @@ const NavList = (props) => {
</>}
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
<ListItemIcon><SettingsIcon/></ListItemIcon>
<ListItemText primary="Settings"/>
<ListItemText primary={t("nav_button_settings")}/>
</ListItemButton>
<ListItemButton onClick={() => openUrl("/docs")}>
<ListItemIcon><ArticleIcon/></ListItemIcon>
<ListItemText primary="Documentation"/>
<ListItemText primary={t("nav_button_documentation")}/>
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon>
<ListItemText primary={t("nav_button_publish_message")}/>
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon>
<ListItemText primary="Add subscription"/>
<ListItemText primary={t("nav_button_subscribe")}/>
</ListItemButton>
</List>
<SubscribeDialog
@@ -151,6 +163,7 @@ const SubscriptionList = (props) => {
}
const SubscriptionItem = (props) => {
const { t } = useTranslation();
const navigate = useNavigate();
const subscription = props.subscription;
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
@@ -160,35 +173,37 @@ const SubscriptionItem = (props) => {
const label = (subscription.baseUrl === window.location.origin)
? subscription.topic
: topicShortUrl(subscription.baseUrl, subscription.topic);
const ariaLabel = (subscription.state === ConnectionState.Connecting)
? `${label} (${t("nav_button_connecting")})`
: label;
const handleClick = async () => {
navigate(routes.forSubscription(subscription));
await subscriptionManager.markNotificationsRead(subscription.id);
};
return (
<ListItemButton onClick={handleClick} selected={props.selected}>
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={label}/>
{subscription.mutedUntil > 0 &&
<ListItemIcon edge="end"><NotificationsOffOutlined /></ListItemIcon>}
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
</ListItemButton>
);
};
const NotificationGrantAlert = (props) => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>Notifications are disabled</AlertTitle>
<Typography gutterBottom>
Grant your browser permission to display desktop notifications.
</Typography>
<AlertTitle>{t("alert_grant_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_grant_description")}</Typography>
<Button
sx={{float: 'right'}}
color="inherit"
size="small"
onClick={props.onRequestPermissionClick}
>
Grant now
{t("alert_grant_button")}
</Button>
</Alert>
<Divider/>
@@ -197,13 +212,12 @@ const NotificationGrantAlert = (props) => {
};
const NotificationNotSupportedAlert = () => {
const { t } = useTranslation();
return (
<>
<Alert severity="warning" sx={{paddingTop: 2}}>
<AlertTitle>Notifications not supported</AlertTitle>
<Typography gutterBottom>
Notifications are not supported in your browser.
</Typography>
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
<Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
</Alert>
<Divider/>
</>

View File

@@ -19,12 +19,14 @@ import {
formatBytes,
formatMessage,
formatShortDateTime,
formatTitle,
openUrl, shortUrl,
formatTitle, maybeAppendActionErrors,
openUrl,
shortUrl,
topicShortUrl,
unmatchedTags
} from "../app/utils";
import IconButton from "@mui/material/IconButton";
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
import {useLiveQuery} from "dexie-react-hooks";
@@ -32,16 +34,13 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager";
import InfiniteScroll from "react-infinite-scroll-component";
import fileApp from "../img/file-app.svg";
import fileAudio from "../img/file-audio.svg";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon";
import {Trans, useTranslation} from "react-i18next";
const Notifications = (props) => {
if (props.mode === "all") {
@@ -60,7 +59,7 @@ const AllSubscriptions = (props) => {
} else if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
}
return <NotificationList key="all" notifications={notifications}/>;
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
}
const SingleSubscription = (props) => {
@@ -71,10 +70,11 @@ const SingleSubscription = (props) => {
} else if (notifications.length === 0) {
return <NoNotifications subscription={subscription}/>;
}
return <NotificationList id={subscription.id} notifications={notifications}/>;
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
}
const NotificationList = (props) => {
const { t } = useTranslation();
const pageSize = 20;
const notifications = props.notifications;
const [snackOpen, setSnackOpen] = useState(false);
@@ -97,7 +97,15 @@ const NotificationList = (props) => {
scrollThreshold={0.7}
scrollableTarget="main"
>
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Container
maxWidth="md"
role="list"
aria-label={t("notifications_list")}
sx={{
marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
}}
>
<Stack spacing={3}>
{notifications.slice(0, count).map(notification =>
<NotificationItem
@@ -109,7 +117,7 @@ const NotificationList = (props) => {
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message="Copied to clipboard"
message={t("notifications_copied_to_clipboard")}
/>
</Stack>
</Container>
@@ -118,66 +126,82 @@ const NotificationList = (props) => {
}
const NotificationItem = (props) => {
const { t } = useTranslation();
const notification = props.notification;
const subscriptionId = notification.subscriptionId;
const attachment = notification.attachment;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
console.log(`[Notifications] Deleting notification ${notification.id}`);
await subscriptionManager.deleteNotification(notification.id)
}
const handleMarkRead = async () => {
console.log(`[Notifications] Marking notification ${notification.id} as read`);
await subscriptionManager.markNotificationRead(notification.id)
}
const handleCopy = (s) => {
navigator.clipboard.writeText(s);
props.onShowSnack();
};
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
const showAttachmentActions = attachment && !expired;
const showClickAction = notification.click;
const showActions = showAttachmentActions || showClickAction;
const hasAttachmentActions = attachment && !expired;
const hasClickAction = notification.click;
const hasUserActions = notification.actions && notification.actions.length > 0;
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
return (
<Card sx={{ minWidth: 275, padding: 1 }}>
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
<CardContent>
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }}>
<CloseIcon />
</IconButton>
<Tooltip title={t("notifications_delete")} enterDelay={500}>
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
<CloseIcon />
</IconButton>
</Tooltip>
{notification.new === 1 &&
<Tooltip title={t("notifications_mark_read")} enterDelay={500}>
<IconButton onClick={handleMarkRead} sx={{ float: 'right', marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}>
<CheckIcon />
</IconButton>
</Tooltip>}
<Typography sx={{ fontSize: 14 }} color="text.secondary">
{date}
{[1,2,4,5].includes(notification.priority) &&
<img
src={priorityFiles[notification.priority]}
alt={`Priority ${notification.priority}`}
alt={t("notifications_priority_x", { priority: notification.priority})}
style={{ verticalAlign: 'bottom' }}
/>}
{notification.new === 1 &&
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label={t("notifications_new_indicator")}>
<circle cx="50" cy="50" r="50" fill="#338574"/>
</svg>}
</Typography>
{notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>{autolink(formatMessage(notification))}</Typography>
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>}
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
</Typography>
{attachment && <Attachment attachment={attachment}/>}
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
{tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">{t("notifications_tags")}: {tags}</Typography>}
</CardContent>
{showActions &&
<CardActions sx={{paddingTop: 0}}>
{showAttachmentActions && <>
<Tooltip title="Copy attachment URL to clipboard">
<Button onClick={() => handleCopy(attachment.url)}>Copy URL</Button>
{hasAttachmentActions && <>
<Tooltip title={t("notifications_attachment_copy_url_title")}>
<Button onClick={() => handleCopy(attachment.url)}>{t("notifications_attachment_copy_url_button")}</Button>
</Tooltip>
<Tooltip title={`Go to ${attachment.url}`}>
<Button onClick={() => openUrl(attachment.url)}>Open attachment</Button>
<Tooltip title={t("notifications_attachment_open_title", { url: attachment.url })}>
<Button onClick={() => openUrl(attachment.url)}>{t("notifications_attachment_open_button")}</Button>
</Tooltip>
</>}
{showClickAction && <>
<Tooltip title="Copy link URL to clipboard">
<Button onClick={() => handleCopy(notification.click)}>Copy link</Button>
{hasClickAction && <>
<Tooltip title={t("notifications_click_copy_url_title")}>
<Button onClick={() => handleCopy(notification.click)}>{t("notifications_click_copy_url_button")}</Button>
</Tooltip>
<Tooltip title={`Go to ${notification.click}`}>
<Button onClick={() => openUrl(notification.click)}>Open link</Button>
<Tooltip title={t("notifications_actions_open_url_title", { url: notification.click })}>
<Button onClick={() => openUrl(notification.click)}>{t("notifications_click_open_button")}</Button>
</Tooltip>
</>}
{hasUserActions && <UserActions notification={notification}/>}
</CardActions>}
</Card>
);
@@ -206,6 +230,7 @@ const priorityFiles = {
};
const Attachment = (props) => {
const { t } = useTranslation();
const attachment = props.attachment;
const expired = attachment.expires && attachment.expires < Date.now()/1000;
const expires = attachment.expires && attachment.expires > Date.now()/1000;
@@ -222,10 +247,10 @@ const Attachment = (props) => {
infos.push(formatBytes(attachment.size));
}
if (expires) {
infos.push(`link expires ${formatShortDateTime(attachment.expires)}`);
infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) }));
}
if (expired) {
infos.push(`download link expired`);
infos.push(t("notifications_attachment_link_expired"));
}
const maybeInfoText = (infos.length > 0) ? <><br/>{infos.join(", ")}</> : null;
@@ -239,7 +264,7 @@ const Attachment = (props) => {
padding: 1,
borderRadius: '4px',
}}>
<Icon type={attachment.type}/>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
@@ -268,7 +293,7 @@ const Attachment = (props) => {
}
}}
>
<Icon type={attachment.type}/>
<AttachmentIcon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
@@ -279,6 +304,7 @@ const Attachment = (props) => {
};
const Image = (props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
@@ -286,6 +312,7 @@ const Image = (props) => {
component="img"
src={props.attachment.url}
loading="lazy"
alt={t("notifications_attachment_image")}
onClick={() => setOpen(true)}
sx={{
marginTop: 2,
@@ -306,6 +333,7 @@ const Image = (props) => {
<Box
component="img"
src={props.attachment.url}
alt={t("notifications_attachment_image")}
loading="lazy"
sx={{
maxWidth: 1,
@@ -323,112 +351,179 @@ const Image = (props) => {
);
}
const Icon = (props) => {
const type = props.type;
let imageFile;
if (!type) {
imageFile = fileDocument;
} else if (type.startsWith('image/')) {
imageFile = fileImage;
} else if (type.startsWith('video/')) {
imageFile = fileVideo;
} else if (type.startsWith('audio/')) {
imageFile = fileAudio;
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
} else {
imageFile = fileDocument;
}
const UserActions = (props) => {
return (
<Box
component="img"
src={imageFile}
loading="lazy"
sx={{
width: '28px',
height: '28px'
}}
/>
<>{props.notification.actions.map(action =>
<UserAction key={action.id} notification={props.notification} action={action}/>)}</>
);
};
const UserAction = (props) => {
const { t } = useTranslation();
const notification = props.notification;
const action = props.action;
if (action.action === "broadcast") {
return (
<Tooltip title={t("notifications_actions_not_supported")}>
<span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span>
</Tooltip>
);
} else if (action.action === "view") {
return (
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
<Button
onClick={() => openUrl(action.url)}
aria-label={t("notifications_actions_open_url_title", { url: action.url })}
>{action.label}</Button>
</Tooltip>
);
} else if (action.action === "http") {
const method = action.method ?? "POST";
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
return (
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}>
<Button
onClick={() => performHttpAction(notification, action)}
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })}
>{label}</Button>
</Tooltip>
);
}
return null; // Others
};
const performHttpAction = async (notification, action) => {
console.log(`[Notifications] Performing HTTP user action`, action);
try {
updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null);
const response = await fetch(action.url, {
method: action.method ?? "POST",
headers: action.headers ?? {},
body: action.body ?? ""
});
console.log(`[Notifications] HTTP user action response`, response);
const success = response.status >= 200 && response.status <= 299;
if (success) {
updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null);
} else {
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`);
}
} catch (e) {
console.log(`[Notifications] HTTP action failed`, e);
updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`);
}
};
const updateActionStatus = (notification, action, progress, error) => {
notification.actions = notification.actions.map(a => {
if (a.id !== action.id) {
return a;
}
return { ...a, progress: progress, error: error };
});
subscriptionManager.updateNotification(notification);
}
const ACTION_PROGRESS_ONGOING = 1;
const ACTION_PROGRESS_SUCCESS = 2;
const ACTION_PROGRESS_FAILED = 3;
const ACTION_LABEL_SUFFIX = {
[ACTION_PROGRESS_ONGOING]: " …",
[ACTION_PROGRESS_SUCCESS]: " ✔",
[ACTION_PROGRESS_FAILED]: " ❌"
};
const NoNotifications = (props) => {
const { t } = useTranslation();
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt="No notifications"/><br />
You haven't received any notifications for this topic yet.
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
{t("notifications_none_for_topic_title")}
</Typography>
<Paragraph>
To send notifications to this topic, simply PUT or POST to the topic URL.
{t("notifications_none_for_topic_description")}
</Paragraph>
<Paragraph>
Example:<br/>
{t("notifications_example")}:<br/>
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
For more detailed instructions, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
<ForMoreDetails/>
</Paragraph>
</VerticallyCenteredContainer>
);
};
const NoNotificationsWithoutSubscription = (props) => {
const { t } = useTranslation();
const subscription = props.subscriptions[0];
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt="No notifications"/><br />
You haven't received any notifications.
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
{t("notifications_none_for_any_title")}
</Typography>
<Paragraph>
To send notifications to a topic, simply PUT or POST to the topic URL. Here's
an example using one of your topics.
{t("notifications_none_for_any_description")}
</Paragraph>
<Paragraph>
Example:<br/>
{t("notifications_example")}:<br/>
<tt>
$ curl -d "Hi" {shortUrl}
</tt>
</Paragraph>
<Paragraph>
For more detailed instructions, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
<ForMoreDetails/>
</Paragraph>
</VerticallyCenteredContainer>
);
};
const NoSubscriptions = () => {
const { t } = useTranslation();
return (
<VerticallyCenteredContainer maxWidth="xs">
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
<img src={logoOutline} height="64" width="64" alt="No topics"/><br />
It looks like you don't have any subscriptions yet.
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
{t("notifications_no_subscriptions_title")}
</Typography>
<Paragraph>
Click the "Add subscription" link to create or subscribe to a topic. After that, you can send messages
via PUT or POST and you'll receive notifications here.
{t("notifications_no_subscriptions_description", {
linktext: t("nav_button_subscribe")
})}
</Paragraph>
<Paragraph>
For more information, check out the <Link href="https://ntfy.sh" target="_blank" rel="noopener">website</Link> or
{" "}<Link href="https://ntfy.sh/docs" target="_blank" rel="noopener">documentation</Link>.
<ForMoreDetails/>
</Paragraph>
</VerticallyCenteredContainer>
);
};
const ForMoreDetails = () => {
return (
<Trans
i18nKey="notifications_more_details"
components={{
websiteLink: <Link href="https://ntfy.sh" target="_blank" rel="noopener"/>,
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
}}
/>
);
};
const Loading = () => {
const { t } = useTranslation();
return (
<VerticallyCenteredContainer>
<Typography variant="h5" color="text.secondary" align="center" sx={{ paddingBottom: 1 }}>
<CircularProgress disableShrink sx={{marginBottom: 1}}/><br />
Loading notifications ...
{t("notifications_loading")}
</Typography>
</VerticallyCenteredContainer>
);

View File

@@ -32,13 +32,15 @@ import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager";
import {playSound} from "../app/utils";
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next";
const Preferences = () => {
return (
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Stack spacing={3}>
<Notifications/>
<Appearance/>
<Users/>
</Stack>
</Container>
@@ -46,10 +48,11 @@ const Preferences = () => {
};
const Notifications = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}}>
<Typography variant="h5">
Notifications
<Card sx={{p: 3}} aria-label={t("prefs_notifications_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_notifications_title")}
</Typography>
<PrefGroup>
<Sound/>
@@ -60,8 +63,9 @@ const Notifications = () => {
);
};
const Sound = () => {
const { t } = useTranslation();
const labelId = "prefSound";
const sound = useLiveQuery(async () => prefs.sound());
const handleChange = async (ev) => {
await prefs.setSound(ev.target.value);
@@ -69,22 +73,22 @@ const Sound = () => {
if (!sound) {
return null; // While loading
}
let description;
if (sound === "none") {
description = t("prefs_notifications_sound_description_none");
} else {
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
}
return (
<Pref title="Notification sound">
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
<div style={{ display: 'flex', width: '100%' }}>
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
<Select value={sound} onChange={handleChange}>
<MenuItem value={"none"}>No sound</MenuItem>
<MenuItem value={"ding"}>Ding</MenuItem>
<MenuItem value={"juntos"}>Juntos</MenuItem>
<MenuItem value={"pristine"}>Pristine</MenuItem>
<MenuItem value={"dadum"}>Dadum</MenuItem>
<MenuItem value={"pop"}>Pop</MenuItem>
<MenuItem value={"pop-swoosh"}>Pop swoosh</MenuItem>
<MenuItem value={"beep"}>Beep</MenuItem>
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
</Select>
</FormControl>
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"}>
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
<PlayArrowIcon />
</IconButton>
</div>
@@ -93,6 +97,8 @@ const Sound = () => {
};
const MinPriority = () => {
const { t } = useTranslation();
const labelId = "prefMinPriority";
const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
@@ -100,15 +106,33 @@ const MinPriority = () => {
if (!minPriority) {
return null; // While loading
}
const priorities = {
1: t("priority_min"),
2: t("priority_low"),
3: t("priority_default"),
4: t("priority_high"),
5: t("priority_max")
}
let description;
if (minPriority === 1) {
description = t("prefs_notifications_min_priority_description_any");
} else if (minPriority === 5) {
description = t("prefs_notifications_min_priority_description_max");
} else {
description = t("prefs_notifications_min_priority_description_x_or_higher", {
number: minPriority,
name: priorities[minPriority]
});
}
return (
<Pref title="Minimum priority">
<Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={minPriority} onChange={handleChange}>
<MenuItem value={1}>Any priority</MenuItem>
<MenuItem value={2}>Low priority and higher</MenuItem>
<MenuItem value={3}>Default priority and higher</MenuItem>
<MenuItem value={4}>High priority and higher</MenuItem>
<MenuItem value={5}>Only max priority</MenuItem>
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
<MenuItem value={4}>{t("prefs_notifications_min_priority_high_and_higher")}</MenuItem>
<MenuItem value={5}>{t("prefs_notifications_min_priority_max_only")}</MenuItem>
</Select>
</FormControl>
</Pref>
@@ -116,22 +140,33 @@ const MinPriority = () => {
};
const DeleteAfter = () => {
const { t } = useTranslation();
const labelId = "prefDeleteAfter";
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value);
}
if (!deleteAfter) {
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
return null; // While loading
}
const description = (() => {
switch (deleteAfter) {
case 0: return t("prefs_notifications_delete_after_never_description");
case 10800: return t("prefs_notifications_delete_after_three_hours_description");
case 86400: return t("prefs_notifications_delete_after_one_day_description");
case 604800: return t("prefs_notifications_delete_after_one_week_description");
case 2592000: return t("prefs_notifications_delete_after_one_month_description");
}
})();
return (
<Pref title="Delete notifications">
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={deleteAfter} onChange={handleChange}>
<MenuItem value={0}>Never</MenuItem>
<MenuItem value={10800}>After three hours</MenuItem>
<MenuItem value={86400}>After one day</MenuItem>
<MenuItem value={604800}>After one week</MenuItem>
<MenuItem value={2592000}>After one month</MenuItem>
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
<MenuItem value={604800}>{t("prefs_notifications_delete_after_one_week")}</MenuItem>
<MenuItem value={2592000}>{t("prefs_notifications_delete_after_one_month")}</MenuItem>
</Select>
</FormControl>
</Pref>
@@ -140,10 +175,7 @@ const DeleteAfter = () => {
const PrefGroup = (props) => {
return (
<div style={{
display: 'flex',
flexWrap: 'wrap'
}}>
<div role="table">
{props.children}
</div>
)
@@ -151,30 +183,47 @@ const PrefGroup = (props) => {
const Pref = (props) => {
return (
<>
<div style={{
flex: '1 0 30%',
display: 'inline-flex',
flexDirection: 'column',
minHeight: '60px',
justifyContent: 'center'
}}>
<b>{props.title}</b>
<div
role="row"
style={{
display: "flex",
flexDirection: "row",
marginTop: "10px",
marginBottom: "20px",
}}
>
<div
role="cell"
id={props.labelId}
aria-label={props.title}
style={{
flex: '1 0 40%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
paddingRight: '30px'
}}
>
<div><b>{props.title}</b></div>
{props.description && <div><em>{props.description}</em></div>}
</div>
<div style={{
flex: '1 0 calc(70% - 50px)',
display: 'inline-flex',
flexDirection: 'column',
minHeight: '60px',
justifyContent: 'center'
}}>
<div
role="cell"
style={{
flex: '1 0 calc(60% - 50px)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
}}
>
{props.children}
</div>
</>
</div>
);
};
const Users = () => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const users = useLiveQuery(() => userManager.all());
@@ -195,19 +244,18 @@ const Users = () => {
}
};
return (
<Card sx={{ padding: 1 }}>
<CardContent>
<Typography variant="h5">
Manage users
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
<CardContent sx={{ paddingBottom: 1 }}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_users_title")}
</Typography>
<Paragraph>
Add/remove users for your protected topics here. Please note that username and password are
stored in the browser's local storage.
{t("prefs_users_description")}
</Paragraph>
{users?.length > 0 && <UserTable users={users}/>}
</CardContent>
<CardActions>
<Button onClick={handleAddClick}>Add user</Button>
<Button onClick={handleAddClick}>{t("prefs_users_add_button")}</Button>
<UserDialog
key={`userAddDialog${dialogKey}`}
open={dialogOpen}
@@ -222,6 +270,7 @@ const Users = () => {
};
const UserTable = (props) => {
const { t } = useTranslation();
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogUser, setDialogUser] = useState(null);
@@ -251,11 +300,11 @@ const UserTable = (props) => {
}
};
return (
<Table size="small">
<Table size="small" aria-label={t("prefs_users_table")}>
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell>Service URL</TableCell>
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
<TableCell>{t("prefs_users_table_base_url_header")}</TableCell>
<TableCell/>
</TableRow>
</TableHead>
@@ -265,13 +314,13 @@ const UserTable = (props) => {
key={user.baseUrl}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell component="th" scope="row">{user.username}</TableCell>
<TableCell>{user.baseUrl}</TableCell>
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
<TableCell align="right">
<IconButton onClick={() => handleEditClick(user)}>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon/>
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)}>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon />
</IconButton>
</TableCell>
@@ -291,6 +340,7 @@ const UserTable = (props) => {
};
const UserDialog = (props) => {
const { t } = useTranslation();
const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -300,8 +350,12 @@ const UserDialog = (props) => {
if (editMode) {
return username.length > 0 && password.length > 0;
}
const baseUrlValid = validUrl(baseUrl);
const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl);
return !baseUrlExists && username.length > 0 && password.length > 0;
return baseUrlValid
&& !baseUrlExists
&& username.length > 0
&& password.length > 0;
})();
const handleSubmit = async () => {
props.onSubmit({
@@ -319,13 +373,14 @@ const UserDialog = (props) => {
}, [editMode, props.user]);
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{editMode ? "Edit user" : "Add user"}</DialogTitle>
<DialogTitle>{editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")}</DialogTitle>
<DialogContent>
{!editMode && <TextField
autoFocus
margin="dense"
id="baseUrl"
label="Service URL, e.g. https://ntfy.sh"
label={t("prefs_users_dialog_base_url_label")}
aria-label={t("prefs_users_dialog_base_url_label")}
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
type="url"
@@ -336,7 +391,8 @@ const UserDialog = (props) => {
autoFocus={editMode}
margin="dense"
id="username"
label="Username, e.g. phil"
label={t("prefs_users_dialog_username_label")}
aria-label={t("prefs_users_dialog_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
type="text"
@@ -346,7 +402,8 @@ const UserDialog = (props) => {
<TextField
margin="dense"
id="password"
label="Password"
label={t("prefs_users_dialog_password_label")}
aria-label={t("prefs_users_dialog_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
@@ -355,11 +412,58 @@ const UserDialog = (props) => {
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onCancel}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? "Save" : "Add"}</Button>
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("prefs_users_dialog_button_save") : t("prefs_users_dialog_button_add")}</Button>
</DialogActions>
</Dialog>
);
};
const Appearance = () => {
const { t } = useTranslation();
return (
<Card sx={{p: 3}} aria-label={t("prefs_appearance_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}>
{t("prefs_appearance_title")}
</Typography>
<PrefGroup>
<Language/>
</PrefGroup>
</Card>
);
};
const Language = () => {
const { t, i18n } = useTranslation();
const labelId = "prefLanguage";
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇭🇺", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
const lang = i18n.language ?? "en";
// Remember: Flags are not languages. Don't put flags next to the language in the list.
// Languages names from: https://www.omniglot.com/language/names.htm
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
return (
<Pref labelId={labelId} title={title}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
<MenuItem value="en">English</MenuItem>
<MenuItem value="bg">Български</MenuItem>
<MenuItem value="cs">Čeština</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="es">Español</MenuItem>
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="hu">Magyar</MenuItem>
<MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
<MenuItem value="ru">Русский</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem>
</Select>
</FormControl>
</Pref>
)
};
export default Preferences;

View File

@@ -0,0 +1,725 @@
import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import {NotificationItem} from "./Notifications";
import theme from "./theme";
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority3 from "../img/priority-3.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {Close} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next";
const PublishDialog = (props) => {
const { t } = useTranslation();
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [message, setMessage] = useState("");
const [messageFocused, setMessageFocused] = useState(true);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [priority, setPriority] = useState(3);
const [clickUrl, setClickUrl] = useState("");
const [attachUrl, setAttachUrl] = useState("");
const [attachFile, setAttachFile] = useState(null);
const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState("");
const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false);
const [showTopicUrl, setShowTopicUrl] = useState("");
const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false);
const [showDelay, setShowDelay] = useState(false);
const showAttachFile = !!attachFile && !showAttachUrl;
const attachFileInput = useRef();
const [attachFileError, setAttachFileError] = useState("");
const [activeRequest, setActiveRequest] = useState(null);
const [status, setStatus] = useState("");
const disabled = !!activeRequest;
const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
const [dropZone, setDropZone] = useState(false);
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => {
window.addEventListener('dragenter', () => {
props.onDragEnter();
setDropZone(true);
});
}, []);
useEffect(() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(!props.baseUrl || !props.topic);
setMessageFocused(!!props.topic); // Focus message only if topic is set
}, [props.baseUrl, props.topic]);
useEffect(() => {
const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
setSendButtonEnabled(valid);
}, [baseUrl, topic, attachFileError]);
useEffect(() => {
setMessage(props.message);
}, [props.message]);
const handleSubmit = async () => {
const url = new URL(topicUrl(baseUrl, topic));
if (title.trim()) {
url.searchParams.append("title", title.trim());
}
if (tags.trim()) {
url.searchParams.append("tags", tags.trim());
}
if (priority && priority !== 3) {
url.searchParams.append("priority", priority.toString());
}
if (clickUrl.trim()) {
url.searchParams.append("click", clickUrl.trim());
}
if (attachUrl.trim()) {
url.searchParams.append("attach", attachUrl.trim());
}
if (filename.trim()) {
url.searchParams.append("filename", filename.trim());
}
if (email.trim()) {
url.searchParams.append("email", email.trim());
}
if (delay.trim()) {
url.searchParams.append("delay", delay.trim());
}
if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
}
const body = (attachFile) ? attachFile : message;
try {
const user = await userManager.get(baseUrl);
const headers = maybeWithBasicAuth({}, user);
const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) {
setStatus(t("publish_dialog_progress_uploading_detail", {
loaded: formatBytes(ev.loaded),
total: formatBytes(ev.total),
percent: Math.round(ev.loaded * 100.0 / ev.total)
}));
} else {
setStatus(t("publish_dialog_progress_uploading"));
}
};
const request = api.publishXHR(url, body, headers, progressFn);
setActiveRequest(request);
await request;
if (!publishAnother) {
props.onClose();
} else {
setStatus(t("publish_dialog_message_published"));
setActiveRequest(null);
}
} catch (e) {
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>);
setActiveRequest(null);
}
};
const checkAttachmentLimits = async (file) => {
try {
const stats = await api.userStats(baseUrl);
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", {
fileSizeLimit: formatBytes(fileSizeLimit),
remainingBytes: formatBytes(remainingBytes)
}));
} else if (fileSizeLimitReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) }));
} else if (quotaReached) {
return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) }));
}
setAttachFileError("");
} catch (e) {
console.log(`[SendDialog] Retrieving attachment limits failed`, e);
setAttachFileError(""); // Reset error (rely on server-side checking)
}
};
const handleAttachFileClick = () => {
attachFileInput.current.click();
};
const handleAttachFileChanged = async (ev) => {
await updateAttachFile(ev.target.files[0]);
};
const handleAttachFileDrop = async (ev) => {
ev.preventDefault();
setDropZone(false);
await updateAttachFile(ev.dataTransfer.files[0]);
};
const updateAttachFile = async (file) => {
setAttachFile(file);
setFilename(file.name);
props.onResetOpenMode();
await checkAttachmentLimits(file);
};
const handleAttachFileDragLeave = () => {
setDropZone(false);
if (props.openMode === PublishDialog.OPEN_MODE_DRAG) {
props.onClose(); // Only close dialog if it was not open before dragging file in
}
};
const handleEmojiClick = (ev) => {
setEmojiPickerAnchorEl(ev.currentTarget);
};
const handleEmojiPick = (emoji) => {
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
};
const handleEmojiClose = () => {
setEmojiPickerAnchorEl(null);
};
const priorities = {
1: { label: t("publish_dialog_priority_min"), file: priority1 },
2: { label: t("publish_dialog_priority_low"), file: priority2 },
3: { label: t("publish_dialog_priority_default"), file: priority3 },
4: { label: t("publish_dialog_priority_high"), file: priority4 },
5: { label: t("publish_dialog_priority_max"), file: priority5 }
};
return (
<>
{dropZone && <DropArea
onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}/>
}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")}</DialogTitle>
<DialogContent>
{dropZone && <DropBox/>}
{showTopicUrl &&
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_base_url_label")}
placeholder={t("publish_dialog_base_url_placeholder")}
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_base_url_label")
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_topic_label")}
placeholder={t("publish_dialog_topic_placeholder")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
autoFocus={!messageFocused}
sx={{flexGrow: 1}}
inputProps={{
"aria-label": t("publish_dialog_topic_label")
}}
/>
</ClosableRow>
}
<TextField
margin="dense"
label={t("publish_dialog_title_label")}
placeholder={t("publish_dialog_title_placeholder")}
value={title}
onChange={ev => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_title_label")
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_message_label")}
placeholder={t("publish_dialog_message_placeholder")}
value={message}
onChange={ev => setMessage(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
rows={5}
autoFocus={messageFocused}
fullWidth
multiline
inputProps={{
"aria-label": t("publish_dialog_message_label")
}}
/>
<div style={{display: 'flex'}}>
<EmojiPicker
anchorEl={emojiPickerAnchorEl}
onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose}
/>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
<InsertEmoticonIcon/>
</DialogIconButton>
<TextField
margin="dense"
label={t("publish_dialog_tags_label")}
placeholder={t("publish_dialog_tags_placeholder")}
value={tags}
onChange={ev => setTags(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_tags_label")
}}
/>
<FormControl
variant="standard"
margin="dense"
sx={{minWidth: 170, maxWidth: 300, flexGrow: 1}}
>
<InputLabel/>
<Select
label={t("publish_dialog_priority_label")}
margin="dense"
value={priority}
onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled}
inputProps={{
"aria-label": t("publish_dialog_priority_label")
}}
>
{[5,4,3,2,1].map(priority =>
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: priority })}/>
<div>{priorities[priority].label}</div>
</div>
</MenuItem>
)}
</Select>
</FormControl>
</div>
{showClickUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
setClickUrl("");
setShowClickUrl(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_click_label")}
placeholder={t("publish_dialog_click_placeholder")}
value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)}
disabled={disabled}
type="url"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("publish_dialog_click_label")
}}
/>
</ClosableRow>
}
{showEmail &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
setEmail("");
setShowEmail(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_email_label")}
placeholder={t("publish_dialog_email_placeholder")}
value={email}
onChange={ev => setEmail(ev.target.value)}
disabled={disabled}
type="email"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_email_label")
}}
/>
</ClosableRow>
}
{showAttachUrl &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
setAttachUrl("");
setFilename("");
setFilenameEdited(false);
setShowAttachUrl(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_attach_label")}
placeholder={t("publish_dialog_attach_placeholder")}
value={attachUrl}
onChange={ev => {
const url = ev.target.value;
setAttachUrl(url);
if (!filenameEdited) {
try {
const u = new URL(url);
const parts = u.pathname.split("/");
if (parts.length > 0) {
setFilename(parts[parts.length-1]);
}
} catch (e) {
// Do nothing
}
}
}}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 5, marginRight: 1}}
inputProps={{
"aria-label": t("publish_dialog_attach_label")
}}
/>
<TextField
margin="dense"
label={t("publish_dialog_filename_label")}
placeholder={t("publish_dialog_filename_placeholder")}
value={filename}
onChange={ev => {
setFilename(ev.target.value);
setFilenameEdited(true);
}}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1}}
inputProps={{
"aria-label": t("publish_dialog_filename_label")
}}
/>
</ClosableRow>
}
<input
type="file"
ref={attachFileInput}
onChange={handleAttachFileChanged}
style={{ display: 'none' }}
aria-hidden={true}
/>
{showAttachFile && <AttachmentBox
file={attachFile}
filename={filename}
disabled={disabled}
error={attachFileError}
onChangeFilename={(f) => setFilename(f)}
onClose={() => {
setAttachFile(null);
setAttachFileError("");
setFilename("");
}}
/>}
{showDelay &&
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
setDelay("");
setShowDelay(false);
}}>
<TextField
margin="dense"
label={t("publish_dialog_delay_label")}
placeholder={t("publish_dialog_delay_placeholder", {
unixTimestamp: "1649029748",
relativeTime: "30m",
naturalLanguage: "tomorrow, 9am"
})}
value={delay}
onChange={ev => setDelay(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
fullWidth
inputProps={{
"aria-label": t("publish_dialog_delay_label")
}}
/>
</ClosableRow>
}
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
{t("publish_dialog_other_features")}
</Typography>
<div>
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} aria-label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
</div>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
<Trans
i18nKey="publish_dialog_details_examples_description"
components={{
docsLink: <Link href="https://ntfy.sh/docs" target="_blank" rel="noopener"/>
}}
/>
</Typography>
</DialogContent>
<DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>{t("publish_dialog_button_cancel_sending")}</Button>}
{!activeRequest &&
<>
<FormControlLabel
label={t("publish_dialog_checkbox_publish_another")}
sx={{marginRight: 2}}
control={
<Checkbox
size="small"
checked={publishAnother}
onChange={(ev) => setPublishAnother(ev.target.checked)}
inputProps={{
"aria-label": t("publish_dialog_checkbox_publish_another")
}} />
} />
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
</>
}
</DialogFooter>
</Dialog>
</>
);
};
const Row = (props) => {
return (
<div style={{display: 'flex'}} role="row">
{props.children}
</div>
);
};
const ClosableRow = (props) => {
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
return (
<Row>
{props.children}
{closable &&
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}>
<Close/>
</DialogIconButton>
}
</Row>
);
};
const DialogIconButton = (props) => {
const sx = props.sx || {};
return (
<IconButton
color="inherit"
size="large"
edge="start"
sx={{height: "45px", marginTop: "17px", ...sx}}
onClick={props.onClick}
disabled={props.disabled}
aria-label={props["aria-label"]}
>
{props.children}
</IconButton>
);
};
const AttachmentBox = (props) => {
const { t } = useTranslation();
const file = props.file;
return (
<>
<Typography variant="body1" sx={{marginTop: 2}}>
{t("publish_dialog_attached_file_title")}
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
padding: 0.5,
borderRadius: '4px',
}}>
<AttachmentIcon type={file.type}/>
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
<ExpandingTextField
minWidth={140}
variant="body2"
placeholder={t("publish_dialog_attached_file_filename_placeholder")}
value={props.filename}
onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled}
/>
<br/>
<Typography variant="body2" sx={{ color: 'text.primary' }}>
{formatBytes(file.size)}
{props.error &&
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
{" "}({props.error})
</Typography>
}
</Typography>
</Box>
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={t("publish_dialog_attached_file_remove")}>
<Close/>
</DialogIconButton>
</Box>
</>
);
};
const ExpandingTextField = (props) => {
const invisibleFieldRef = useRef();
const [textWidth, setTextWidth] = useState(props.minWidth);
const determineTextWidth = () => {
const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
if (!boundingRect) {
return props.minWidth;
}
return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
};
useEffect(() => {
setTextWidth(determineTextWidth() + 5);
}, [props.value]);
return (
<>
<Typography
ref={invisibleFieldRef}
component="span"
variant={props.variant}
aria-hidden={true}
sx={{position: "absolute", left: "-200%"}}
>
{props.value}
</Typography>
<TextField
margin="dense"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
type="text"
variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
inputProps={{
style: { paddingBottom: 0, paddingTop: 0 },
"aria-label": props.placeholder
}}
disabled={props.disabled}
/>
</>
)
};
const DropArea = (props) => {
const allowDrag = (ev) => {
// This is where we could disallow certain files to be dragged in.
// For now we allow all files.
ev.dataTransfer.dropEffect = 'copy';
ev.preventDefault();
};
return (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10002,
}}
onDrop={props.onDrop}
onDragEnter={allowDrag}
onDragOver={allowDrag}
onDragLeave={props.onDragLeave}
/>
);
};
const DropBox = () => {
const { t } = useTranslation();
return (
<Box sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10000,
backgroundColor: "#ffffffbb"
}}>
<Box
sx={{
position: 'absolute',
border: '3px dashed #ccc',
borderRadius: '5px',
left: "40px",
top: "40px",
right: "40px",
bottom: "40px",
zIndex: 10001,
display: 'flex',
justifyContent: "center",
alignItems: "center",
}}
>
<Typography variant="h5">{t("publish_dialog_drop_file_here")}</Typography>
</Box>
</Box>
);
}
PublishDialog.OPEN_MODE_DEFAULT = "default";
PublishDialog.OPEN_MODE_DRAG = "drag";
export default PublishDialog;

View File

@@ -3,7 +3,6 @@ import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
@@ -11,10 +10,11 @@ import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/mate
import theme from "./theme";
import api from "../app/Api";
import {topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
import {useTranslation} from "react-i18next";
const publicBaseUrl = "https://ntfy.sh";
@@ -52,6 +52,7 @@ const SubscribeDialog = (props) => {
};
const SubscribePage = (props) => {
const { t } = useTranslation();
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [errorText, setErrorText] = useState("");
const baseUrl = (anotherServerVisible) ? props.baseUrl : window.location.origin;
@@ -61,12 +62,12 @@ const SubscribePage = (props) => {
.filter(s => s !== window.location.origin);
const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined
const username = (user) ? user.username : "anonymous";
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
const success = await api.auth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
if (user) {
setErrorText(`User ${username} not authorized`);
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
return;
} else {
props.onNeedsLogin();
@@ -91,28 +92,37 @@ const SubscribePage = (props) => {
})();
return (
<>
<DialogTitle>Subscribe to topic</DialogTitle>
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
Topics may not be password-protected, so choose a name that's not easy to guess.
Once subscribed, you can PUT/POST notifications.
{t("subscribe_dialog_subscribe_description")}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="topic"
placeholder="Topic name, e.g. phil_alerts"
inputProps={{ maxLength: 64 }}
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
value={props.topic}
onChange={ev => props.setTopic(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
maxLength: 64,
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
}}
/>
<FormControlLabel
sx={{pt: 1}}
control={<Checkbox onChange={handleUseAnotherChanged}/>}
label="Use another server" />
control={
<Checkbox
onChange={handleUseAnotherChanged}
inputProps={{
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
}}
/>
}
label={t("subscribe_dialog_subscribe_use_another_label")} />
{anotherServerVisible && <Autocomplete
freeSolo
options={existingBaseUrls}
@@ -120,19 +130,25 @@ const SubscribePage = (props) => {
inputValue={props.baseUrl}
onInputChange={(ev, newVal) => props.setBaseUrl(newVal)}
renderInput={ (params) =>
<TextField {...params} placeholder={window.location.origin} variant="standard"/>
<TextField
{...params}
placeholder={window.location.origin}
variant="standard"
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
/>
}
/>}
</DialogContent>
<DialogFooter status={errorText}>
<Button onClick={props.onCancel}>Cancel</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>Subscribe</Button>
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
<Button onClick={handleSubscribe} disabled={!subscribeButtonEnabled}>{t("subscribe_dialog_subscribe_button_subscribe")}</Button>
</DialogFooter>
</>
);
};
const LoginPage = (props) => {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [errorText, setErrorText] = useState("");
@@ -143,7 +159,7 @@ const LoginPage = (props) => {
const success = await api.auth(baseUrl, topic, user);
if (!success) {
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
setErrorText(`User ${username} not authorized`);
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
return;
}
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
@@ -152,63 +168,45 @@ const LoginPage = (props) => {
};
return (
<>
<DialogTitle>Login required</DialogTitle>
<DialogTitle>{t("subscribe_dialog_login_title")}</DialogTitle>
<DialogContent>
<DialogContentText>
This topic is password-protected. Please enter username and
password to subscribe.
{t("subscribe_dialog_login_description")}
</DialogContentText>
<TextField
autoFocus
margin="dense"
id="username"
label="Username, e.g. phil"
label={t("subscribe_dialog_login_username_label")}
value={username}
onChange={ev => setUsername(ev.target.value)}
type="text"
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_username_label")
}}
/>
<TextField
margin="dense"
id="password"
label="Password"
label={t("subscribe_dialog_login_password_label")}
type="password"
value={password}
onChange={ev => setPassword(ev.target.value)}
fullWidth
variant="standard"
inputProps={{
"aria-label": t("subscribe_dialog_login_password_label")
}}
/>
</DialogContent>
<DialogFooter status={errorText}>
<Button onClick={props.onBack}>Back</Button>
<Button onClick={handleLogin}>Login</Button>
<Button onClick={props.onBack}>{t("subscribe_dialog_login_button_back")}</Button>
<Button onClick={handleLogin}>{t("subscribe_dialog_login_button_login")}</Button>
</DialogFooter>
</>
);
};
const DialogFooter = (props) => {
return (
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingTop: '8px 24px',
paddingBottom: '8px 24px',
}}>
<DialogContentText sx={{
margin: '0px',
paddingTop: '8px',
}}>
{props.status}
</DialogContentText>
<DialogActions>
{props.children}
</DialogActions>
</Box>
);
};
export default SubscribeDialog;

View File

@@ -6,6 +6,7 @@ import notifier from "../app/Notifier";
import routes from "./routes";
import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller";
import pruner from "../app/Pruner";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -67,29 +68,13 @@ export const useAutoSubscribe = (subscriptions, selected) => {
};
/**
* Migrate the 'topics' item in localStorage to the subscriptionManager. This is only done once to migrate away
* from the old web UI.
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
* up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
*/
export const useLocalStorageMigration = () => {
const [hasRun, setHasRun] = useState(false);
export const useBackgroundProcesses = () => {
useEffect(() => {
if (hasRun) {
return;
}
const topicsStr = localStorage.getItem("topics");
if (topicsStr) {
const topics = JSON.parse(topicsStr).filter(topic => topic !== "");
if (topics.length > 0) {
(async () => {
for (const topic of topics) {
const baseUrl = window.location.origin;
const subscription = await subscriptionManager.add(baseUrl, topic);
poller.pollInBackground(subscription); // Dangle!
}
localStorage.removeItem("topics");
})();
}
}
setHasRun(true);
poller.startWorker();
pruner.startWorker();
}, []);
}

View File

@@ -0,0 +1,29 @@
import i18n from 'i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next';
// Translations using i18next
// - Options: https://www.i18next.com/overview/configuration-options
// - Browser Language Detector: https://github.com/i18next/i18next-browser-languageDetector
// - HTTP Backend (load files via fetch): https://github.com/i18next/i18next-http-backend
//
// See example project here:
// https://github.com/i18next/react-i18next/tree/master/example/react
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: true,
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
backend: {
loadPath: '/static/langs/{{lng}}.json',
}
});
export default i18n;

View File

@@ -13,4 +13,5 @@ const routes = {
return `/${subscription.topic}`;
}
};
export default routes;

View File

@@ -0,0 +1 @@
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M4.882 16.057c-.33-.114-.636-.42-.795-.797-.084-.202-.1-.292-.1-.578-.001-.302.011-.366.116-.6a1.44 1.44 0 01.652-.704l.205-.106h14.077l.205.106c.756.39 1.01 1.376.548 2.128a1.217 1.217 0 01-.588.515l-.201.091-6.985-.001c-5.641-.002-7.013-.012-7.134-.054zM4.858 10.595c-.33-.114-.635-.42-.794-.797-.085-.201-.1-.292-.101-.578 0-.302.012-.366.116-.6a1.44 1.44 0 01.653-.704l.205-.106h14.076l.205.106c.757.39 1.01 1.377.548 2.128a1.217 1.217 0 01-.587.515l-.202.092-6.984-.002c-5.642-.002-7.013-.012-7.135-.054z" fill="#0091ff"/></svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@@ -1,8 +1,6 @@
import * as React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import App from './components/App';
ReactDOM.render(
<App />,
document.querySelector('#root')
);
const root = createRoot(document.querySelector('#root'));
root.render(<App />);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.