Compare commits

...

28 Commits

Author SHA1 Message Date
binwiederhier
477c9d3ed5 Bump 2023-03-04 16:51:55 -05:00
binwiederhier
e44f0ef6e7 Release notes 2023-03-04 09:36:53 -05:00
binwiederhier
6f4b260035 Tiny changes 2023-03-04 09:32:29 -05:00
binwiederhier
bb7a751e58 Merge branch 'main' into matrix-507-reject 2023-03-04 09:24:52 -05:00
binwiederhier
97c9266cc8 Release notes 2023-03-04 09:24:19 -05:00
binwiederhier
a139a3df89 Wording 2023-03-04 09:19:58 -05:00
binwiederhier
346d8d7967 Works 2023-03-03 22:22:07 -05:00
binwiederhier
3eeeac2c13 Merge branch 'enable-subscriber-rate-limiting' into matrix-507-reject 2023-03-03 20:34:33 -05:00
binwiederhier
94f6d2d5b5 Rename flag 2023-03-03 20:23:18 -05:00
binwiederhier
1c4420bca8 EnableRateVisitor flag 2023-03-03 14:55:37 -05:00
binwiederhier
ecff7258ba Release log 2023-03-03 14:04:50 -05:00
binwiederhier
72d4f67524 Merge branch 'main' of github.com:binwiederhier/ntfy 2023-03-03 13:57:00 -05:00
binwiederhier
1ce92714c4 Add visitor_seen to the log context 2023-03-03 13:56:48 -05:00
Philipp C. Heckel
1c6c2cf332 Merge pull request #651 from Xinayder/fix-token-auth
Fix publish command preferring default user instead of token auth
2023-03-03 13:56:14 -05:00
Alexandre Oliveira
9d42ee9391 Fix publish command preferring default user instead of token auth
Closes #650
2023-03-03 17:49:18 +01:00
Philipp C. Heckel
b62204054f Update 1_bug_report.md 2023-03-03 07:15:39 -05:00
binwiederhier
166dc6b4fa Merge branch 'main' of github.com:binwiederhier/ntfy 2023-03-02 22:29:00 -05:00
binwiederhier
02a1e99db2 Issue templates 2023-03-02 22:28:46 -05:00
binwiederhier
250637cf92 Added Danish 2023-03-02 21:48:21 -05:00
binwiederhier
b46de7402d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2023-03-02 21:45:07 -05:00
Philipp C. Heckel
9334a94886 Create SECURITY.md 2023-03-02 21:39:04 -05:00
Philipp C. Heckel
9b9aa4306a Merge pull request #647 from Sharknoon/fix-dockerfile
Added informative labels to Dockerfile
2023-03-02 21:01:44 -05:00
binwiederhier
90db1283dd Allow SMTP servers without auth 2023-03-02 20:25:13 -05:00
Josua Frank
8cc00a6ac6 refined dockerfile 2023-03-02 14:59:49 +01:00
Anders H
315034c8cd Translated using Weblate (Danish)
Currently translated at 65.2% (223 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/da/
2023-03-01 23:38:21 +01:00
ButterflyOfFire
23ac9d44a1 Translated using Weblate (Arabic)
Currently translated at 82.4% (282 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ar/
2023-03-01 23:38:20 +01:00
Bartosz Moczulski
70db2f994c Translated using Weblate (Polish)
Currently translated at 69.2% (237 of 342 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pl/
2023-03-01 23:38:20 +01:00
binwiederhier
4d22ccc7f6 WIP Reject 507s after a while 2023-02-28 22:25:13 -05:00
36 changed files with 803 additions and 105 deletions

26
.github/ISSUE_TEMPLATE/1_bug_report.md vendored Normal file
View File

@@ -0,0 +1,26 @@
---
name: 🐛 Bug Report
about: Report any errors and problems
title: ''
labels: '🪲 bug'
assignees: ''
---
:lady_beetle: **Describe the bug**
<!-- A clear and concise description of the problem. -->
:computer: **Components impacted**
<!-- ntfy server, Android app, iOS app, web app -->
:bulb: **Screenshots and/or logs**
<!--
If applicable, add screenshots or share logs help explain your problem.
To get logs from the ...
- ntfy server: Enable "log-level: trace" in your server.yml file
- Android app: Go to "Settings" -> "Record logs", then eventually "Copy/upload logs"
- web app: Press "F12" and find the "Console" window
-->
:crystal_ball: **Additional context**
<!-- Add any other context about the problem here. -->

View File

@@ -0,0 +1,26 @@
---
name: 💡 Feature/Enhancement Request
about: Got a great idea? Let us know!
title: ''
labels: 'enhancement'
assignees: ''
---
<!--
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
sooner, and there are more people there to help!
- Discord: https://discord.gg/cT7ECsZj9w
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
-->
:bulb: **Idea**
<!-- Share your thoughts; try to be detailed if you can -->
:computer: **Target components**
<!-- Where should this feature/enhancement be added? -->
<!-- e.g. ntfy server, Android app, iOS app, web app -->

View File

@@ -0,0 +1,21 @@
---
name: 🆘 I need help with ...
about: Installing ntfy, configuring the app, etc.
title: ''
labels: 'tech-support'
assignees: ''
---
<!--
STOP!
This is not the right place to ask for help. Consider asking on Discord/Matrix instead.
You'll usually get an answer sooner, and there are more people there to help!
- Discord: https://discord.gg/cT7ECsZj9w
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
-->

21
.github/ISSUE_TEMPLATE/4_question.md vendored Normal file
View File

@@ -0,0 +1,21 @@
---
name: ❓ Question
about: Ask a question about ntfy
title: ''
labels: 'question'
assignees: ''
---
<!--
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
sooner, and there are more people there to help!
- Discord: https://discord.gg/cT7ECsZj9w
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
-->
:question: **Question**
<!-- Go ahead and ask your question here :) -->

View File

@@ -1,5 +1,13 @@
FROM alpine
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
LABEL org.opencontainers.image.url="https://ntfy.sh/"
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
LABEL org.opencontainers.image.title="ntfy"
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
COPY ntfy /usr/bin

View File

@@ -13,9 +13,14 @@
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, **without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do so since ntfy is open source.
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)
notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer,
**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do
so since ntfy is open source.
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android) available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android)
available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),
as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
<p>
<img src="web/public/static/img/screenshot-curl.png" height="180">

10
SECURITY.md Normal file
View File

@@ -0,0 +1,10 @@
# Security Policy
## Supported Versions
As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date.
## Reporting a Vulnerability
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).

View File

@@ -171,7 +171,7 @@ func execPublish(c *cli.Context) error {
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
}
options = append(options, client.WithBasicAuth(user, pass))
} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
} else if token == "" && conf.DefaultUser != "" && conf.DefaultPassword != nil {
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
}
if pid > 0 {

View File

@@ -81,6 +81,7 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
@@ -149,6 +150,7 @@ func execServe(c *cli.Context) error {
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
totalTopicLimit := c.Int("global-topic-limit")
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
@@ -177,8 +179,8 @@ func execServe(c *cli.Context) error {
return errors.New("if set, certificate file must exist")
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
return errors.New("if listen-https is set, both key-file and cert-file must be set")
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set")
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
} else if smtpServerListen != "" && smtpServerDomain == "" {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
@@ -304,6 +306,7 @@ func execServe(c *cli.Context) error {
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
conf.BehindProxy = behindProxy
conf.StripeSecretKey = stripeSecretKey
conf.StripeWebhookKey = stripeWebhookKey

View File

@@ -932,6 +932,25 @@ If this ever happens, there will be a log message that looks something like this
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
```
### Subscriber-based rate limiting
By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment
size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits
of a topic's subscriber, instead of the limits of the publisher.**
If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
publishers (e.g. Matrix/Mastodon servers) are allowed to send.
Once enabled, a client may send a `Rate-Topics: <topic1>,<topic2>,...` header when subscribing to topics via
HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic.
UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage`
response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's
`visitor-message-daily-limit`.
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
## Tuning for scale
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
@@ -1191,6 +1210,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |

View File

@@ -26,37 +26,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_x86_64.tar.gz
tar zxvf ntfy_2.1.1_linux_x86_64.tar.gz
sudo cp -a ntfy_2.1.1_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_x86_64.tar.gz
tar zxvf ntfy_2.1.2_linux_x86_64.tar.gz
sudo cp -a ntfy_2.1.2_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.2_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.tar.gz
tar zxvf ntfy_2.1.1_linux_armv6.tar.gz
sudo cp -a ntfy_2.1.1_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_armv6.tar.gz
tar zxvf ntfy_2.1.2_linux_armv6.tar.gz
sudo cp -a ntfy_2.1.2_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.2_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.tar.gz
tar zxvf ntfy_2.1.1_linux_armv7.tar.gz
sudo cp -a ntfy_2.1.1_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_armv7.tar.gz
tar zxvf ntfy_2.1.2_linux_armv7.tar.gz
sudo cp -a ntfy_2.1.2_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.2_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.tar.gz
tar zxvf ntfy_2.1.1_linux_arm64.tar.gz
sudo cp -a ntfy_2.1.1_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_arm64.tar.gz
tar zxvf ntfy_2.1.2_linux_arm64.tar.gz
sudo cp -a ntfy_2.1.2_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.2_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -106,7 +106,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -114,7 +114,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -122,7 +122,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -130,7 +130,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,28 +140,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_macOS_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_macOS_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_macOS_all.tar.gz > ntfy_2.1.1_macOS_all.tar.gz
tar zxvf ntfy_2.1.1_macOS_all.tar.gz
sudo cp -a ntfy_2.1.1_macOS_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_macOS_all.tar.gz > ntfy_2.1.2_macOS_all.tar.gz
tar zxvf ntfy_2.1.2_macOS_all.tar.gz
sudo cp -a ntfy_2.1.2_macOS_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.1.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.1.2_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -212,7 +212,7 @@ ntfy --help
## Windows
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_windows_x86_64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.2/ntfy_2.1.2_windows_x86_64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).

View File

@@ -2,6 +2,30 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
## ntfy server v2.1.2
Released March 4, 2023
This is a hotfix release, mostly to combat the ridiculous amount of Matrix requests with invalid/dead pushkeys, and the
corresponding HTTP 507 responses the ntfy.sh server is sending out. We're up to >600k HTTP 507 responses 🤦. This release
solves this issue by rejecting Matrix pushkeys, if nobody has subscribed to the corresponding topic to 12 hours.
The release furthermore reverts the default rate limiting behavior for UnifiedPush to be publisher-based, and introduces
a flag to enable [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) for high volume servers.
**Features:**
* Support SMTP servers without auth ([#645](https://github.com/binwiederhier/ntfy/issues/645), thanks to [@Sharknoon](https://github.com/Sharknoon) for reporting)
**Bug fixes + maintenance:**
* Token auth doesn't work if default user credentials are defined in `client.yml` ([#650](https://github.com/binwiederhier/ntfy/issues/650), thanks to [@Xinayder](https://github.com/Xinayder))
* Add `visitor-subscriber-rate-limiting` flag to allow enabling [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) (off by default now, [#649](https://github.com/binwiederhier/ntfy/issues/649)/[#655](https://github.com/binwiederhier/ntfy/pull/655), thanks to [@barathrm](https://github.com/barathrm) for reporting, and to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)
* Reject Matrix pushkey after 12 hours of inactivity on a topic, if `visitor-subscriber-rate-limiting` is enabled ([#643](https://github.com/binwiederhier/ntfy/pull/643), thanks to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)
**Additional languages:**
* Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/))
## ntfy server v2.1.1
Released March 1, 2023

8
go.mod
View File

@@ -27,7 +27,7 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.10.0
github.com/stripe/stripe-go/v74 v74.9.0
github.com/stripe/stripe-go/v74 v74.10.0
)
require (
@@ -53,12 +53,12 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/text v0.8.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.2 // indirect
google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514 // indirect
google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488 // indirect
google.golang.org/grpc v1.53.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

13
go.sum
View File

@@ -103,6 +103,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.9.0 h1:yQ3O8jmtoAjKARzjLGmwYj2ZxqYbdtWVjFeovNGDtjg=
github.com/stripe/stripe-go/v74 v74.9.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
github.com/stripe/stripe-go/v74 v74.10.0 h1:Edd5uO1/41wyd163ZTTA8b+8t/wVgdnJQk3Ry1lbLIs=
github.com/stripe/stripe-go/v74 v74.10.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
@@ -125,6 +127,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
@@ -142,19 +145,27 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/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/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -178,6 +189,8 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514 h1:rtNKfB++wz5mtDY2t5C8TXlU5y52ojSu7tZo0z7u8eQ=
google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA=
google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488 h1:QQF+HdiI4iocoxUjjpLgvTYDHKm99C/VtTBFnfiCJos=
google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=

View File

@@ -3,6 +3,7 @@ package log
import (
"encoding/json"
"fmt"
"heckel.io/ntfy/util"
"log"
"os"
"sort"
@@ -11,12 +12,11 @@ import (
)
const (
fieldTag = "tag"
fieldError = "error"
fieldTimeTaken = "time_taken_ms"
fieldExitCode = "exit_code"
tagStdLog = "stdlog"
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
fieldTag = "tag"
fieldError = "error"
fieldTimeTaken = "time_taken_ms"
fieldExitCode = "exit_code"
tagStdLog = "stdlog"
)
// Event represents a single log event
@@ -143,7 +143,7 @@ func (e *Event) Render(l Level, message string, v ...any) string {
}
e.Message = fmt.Sprintf(message, v...)
e.Level = l
e.Timestamp = e.time.Format(timestampFormat)
e.Timestamp = util.FormatTime(e.time)
if !appliedContexters {
e.applyContexters()
}

View File

@@ -124,6 +124,7 @@ type Config struct {
VisitorAuthFailureLimitBurst int
VisitorAuthFailureLimitReplenish time.Duration
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
BehindProxy bool
StripeSecretKey string
StripeWebhookKey string
@@ -198,10 +199,12 @@ func NewConfig() *Config {
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
VisitorSubscriberRateLimiting: false,
BehindProxy: false,
StripeSecretKey: "",
StripeWebhookKey: "",
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
BillingContact: "",
EnableWeb: true,
EnableSignup: false,
EnableLogin: false,

View File

@@ -127,5 +127,5 @@ var (
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
errHTTPInsufficientStorage = &errHTTP{50701, http.StatusInsufficientStorage, "internal server error: cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
)

View File

@@ -31,7 +31,7 @@ const (
)
var (
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusInsufficientStorage}
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage}
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
)

View File

@@ -585,9 +585,9 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
return writeMatrixDiscoveryResponse(w)
}
func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) {
t := fromContext[topic](r, contextTopic)
vrate := fromContext[visitor](r, contextRateVisitor)
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) {
t := fromContext[*topic](r, contextTopic)
vrate := fromContext[*visitor](r, contextRateVisitor)
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return nil, err
@@ -597,12 +597,12 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
if e != nil {
return nil, e.With(t)
}
if unifiedpush && t.RateVisitor() == nil {
if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see
// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
return nil, errHTTPInsufficientStorage.With(t)
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() {
@@ -670,7 +670,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
m, err := s.handlePublishWithoutResponse(r, v)
m, err := s.handlePublishInternal(r, v)
if err != nil {
return err
}
@@ -678,8 +678,15 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
_, err := s.handlePublishWithoutResponse(r, v)
_, err := s.handlePublishInternal(r, v)
if err != nil {
if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode {
topic := fromContext[*topic](r, contextTopic)
pushKey := fromContext[string](r, contextMatrixPushKey)
if time.Since(topic.LastAccess()) > matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter {
return writeMatrixResponse(w, pushKey)
}
}
return err
}
return writeMatrixSuccess(w)
@@ -1008,6 +1015,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
if poll {
for _, t := range topics {
t.Keepalive()
}
return s.sendOldMessages(topics, since, scheduled, v, sub)
}
ctx, cancel := context.WithCancel(context.Background())
@@ -1034,8 +1044,16 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
case <-r.Context().Done():
return nil
case <-time.After(s.config.KeepaliveInterval):
logvr(v, r).Tag(tagSubscribe).Trace("Sending keepalive message")
ev := logvr(v, r).Tag(tagSubscribe)
if len(topics) == 1 {
ev.With(topics[0]).Trace("Sending keepalive message to %s", topics[0].ID)
} else {
ev.Trace("Sending keepalive message to %d topics", len(topics))
}
v.Keepalive()
for _, t := range topics {
t.Keepalive()
}
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
return err
}
@@ -1123,6 +1141,9 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
return &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: "subscription was canceled"}
case <-time.After(s.config.KeepaliveInterval):
v.Keepalive()
for _, t := range topics {
t.Keepalive()
}
if err := ping(); err != nil {
return err
}
@@ -1145,6 +1166,9 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
}
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
if poll {
for _, t := range topics {
t.Keepalive()
}
return s.sendOldMessages(topics, since, scheduled, v, sub)
}
subscriberIDs := make([]int, 0)
@@ -1188,14 +1212,19 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
// maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published
// to that topic will be rate limited against the rate visitor instead of the publishing visitor.
//
// Setting the rate visitor is ony allowed if
// Setting the rate visitor is ony allowed if the `visitor-subscriber-rate-limiting` setting is enabled, AND
// - auth-file is not set (everything is open by default)
// - the topic is reserved, and v.user is the owner
// - the topic is not reserved, and v.user has write access
// - or the topic is reserved, and v.user is the owner
// - or the topic is not reserved, and v.user has write access
//
// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition
// until the Android app will send the "Rate-Topics" header.
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
// Bail out if not enabled
if !s.config.VisitorSubscriberRateLimiting {
return nil
}
// Make a list of topics that we'll actually set the RateVisitor on
eligibleRateTopics := make([]*topic, 0)
for _, t := range topics {

View File

@@ -117,18 +117,19 @@
# attachment-expiry-duration: "3h"
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
# below (visitor-email-limit-burst & visitor-email-limit-burst).
# messages will additionally be sent out as e-mail using an external SMTP server.
#
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
#
# - smtp-sender-addr is the hostname:port of the SMTP server
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user
# - smtp-sender-from is the e-mail address of the sender
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
#
# smtp-sender-addr:
# smtp-sender-from:
# smtp-sender-user:
# smtp-sender-pass:
# smtp-sender-from:
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
# emails to a topic e-mail address to publish messages to a topic.
@@ -234,6 +235,21 @@
# visitor-attachment-total-size-limit: "100M"
# visitor-attachment-daily-bandwidth-limit: "500M"
# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush)
#
# If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
# publishers (e.g. Matrix/Mastodon servers) are allowed to send.
#
# Once enabled, a client may send a "Rate-Topics: <topic1>,<topic2>,..." header when subscribing to topics via
# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
# to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic.
#
# UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if
# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit".
#
# visitor-subscriber-rate-limiting: false
# Payments integration via Stripe
#
# - stripe-secret-key is the key used for the Stripe API communication. Setting this values

View File

@@ -705,7 +705,7 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
t.Parallel()
conf := newTestConfigWithAuthFile(t)
conf.AuthDefault = user.PermissionReadWrite
conf.AuthStatsQueueWriterInterval = 100 * time.Millisecond
conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond
s := newTestServer(t, conf)
defer s.closeDatabases()

View File

@@ -2,6 +2,7 @@ package server
import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"strings"
)
@@ -34,16 +35,20 @@ func (s *Server) execManager() {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.topics {
subs := t.SubscribersCount()
log.Tag(tagManager).With(t).Trace("- topic %s: %d subscribers", t.ID, subs)
msgs, exists := messageCounts[t.ID]
if t.Stale() && (!exists || msgs == 0) {
log.Tag(tagManager).With(t).Trace("Deleting empty topic %s", t.ID)
subs, lastAccess := t.Stats()
ev := log.Tag(tagManager).With(t)
if t.Stale() {
if ev.IsTrace() {
ev.Trace("- topic %s: Deleting stale topic (%d subscribers, accessed %s)", t.ID, subs, util.FormatTime(lastAccess))
}
emptyTopics++
delete(s.topics, t.ID)
continue
} else {
if ev.IsTrace() {
ev.Trace("- topic %s: %d subscribers, accessed %s", t.ID, subs, util.FormatTime(lastAccess))
}
subscribers += subs
}
subscribers += subs
}
}).
Debug("Removed %d empty topic(s)", emptyTopics)

View File

@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"strings"
"time"
)
// Matrix Push Gateway / UnifiedPush / ntfy integration:
@@ -71,6 +72,14 @@ type matrixResponse struct {
Rejected []string `json:"rejected"`
}
const (
// matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter is the time after which a Matrix response
// will return an HTTP 200 with the push key (i.e. "rejected":["<pushkey>"]}), if no rate visitor has been set on
// the topic. Rejecting the push key will instruct the Matrix server to invalidate the pushkey and stop sending
// messages to it. This must be longer than topicExpungeAfter. See https://spec.matrix.org/v1.6/push-gateway-api/
matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter = 12 * time.Hour
)
// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages
//
// If the push key is set, the app server will remove it and will never send messages using the same
@@ -126,6 +135,9 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
if r.Header.Get("X-Forwarded-For") != "" {
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
}
newRequest = withContext(newRequest, map[contextKey]any{
contextMatrixPushKey: pushKey,
})
return newRequest, nil
}

View File

@@ -11,6 +11,7 @@ type contextKey int
const (
contextRateVisitor contextKey = iota + 2586
contextTopic
contextMatrixPushKey
)
func (s *Server) limitRequests(next handleFunc) handleFunc {

View File

@@ -15,6 +15,7 @@ import (
"net/netip"
"os"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"testing"
@@ -1171,6 +1172,56 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
require.Equal(t, 400, response.Code)
}
func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
subFn := func(v *visitor, msg *message) error {
return nil
}
// Publish and check last access
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
"Cache": "no",
})
require.Equal(t, 200, response.Code)
require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2)
require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2)
// Topic won't get pruned
s.execManager()
require.NotNil(t, s.topics["mytopic"])
// Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber)
subID := s.topics["mytopic"].Subscribe(subFn, "", func() {})
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
s.execManager()
require.NotNil(t, s.topics["mytopic"])
// It'll finally get pruned now that there are no subscribers and last access is 17 hours ago
s.topics["mytopic"].Unsubscribe(subID)
s.execManager()
require.Nil(t, s.topics["mytopic"])
}
func TestServer_TopicKeepaliveOnPoll(t *testing.T) {
t.Parallel()
s := newTestServer(t, newTestConfig(t))
// Create topic by polling once
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
// Mess with last access time
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
// Poll again and check keepalive time
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2)
require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2)
}
func TestServer_UnifiedPushDiscovery(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic?up=1", "", nil)
@@ -1291,13 +1342,41 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) {
}
func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
c := newTestConfig(t)
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
require.Equal(t, 507, response.Code)
require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) {
c := newTestConfig(t)
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
// No success if no rate visitor set (this also creates the topic in memory)
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
require.Equal(t, 507, response.Code)
require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)
require.Nil(t, s.topics["mytopic"].rateVisitor)
// Fake: This topic has been around for 13 hours without a rate visitor
s.topics["mytopic"].lastAccess = time.Now().Add(-13 * time.Hour)
// Same request should now return HTTP 200 with a rejected pushkey
response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"rejected":["http://127.0.0.1:12345/mytopic?up=1"]}`, strings.TrimSpace(response.Body.String()))
// Slightly unrelated: Test that topic is pruned after 16 hours
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
s.execManager()
require.Nil(t, s.topics["mytopic"])
}
func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
@@ -2029,6 +2108,7 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.VisitorRequestLimitBurst = 3
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor
@@ -2040,6 +2120,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
}, subscriber1Fn)
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String())
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
subscriber2Fn := func(r *http.Request) {
@@ -2048,6 +2129,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String())
// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the
// GET request before is also counted towards the request limiter.
@@ -2079,9 +2161,47 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
require.Equal(t, 429, rr.Code)
}
func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.VisitorRequestLimitBurst = 3
c.VisitorSubscriberRateLimiting = false
s := newTestServer(t, c)
// Subscriber rate limiting is disabled!
// Registering visitor 1.2.3.4 to topic has no effect
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
"Rate-Topics": "subscriber1topic",
}, func(r *http.Request) {
r.RemoteAddr = "1.2.3.4"
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
require.Nil(t, s.topics["subscriber1topic"].rateVisitor)
// Registering visitor 8.7.7.1 to topic has no effect
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) {
r.RemoteAddr = "8.7.7.1"
})
require.Equal(t, 200, rr.Code)
require.Equal(t, "", rr.Body.String())
require.Nil(t, s.topics["up012345678912"].rateVisitor)
// Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9
for i := 0; i < 3; i++ {
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
require.Equal(t, 200, rr.Code)
}
rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil)
require.Equal(t, 429, rr.Code)
rr = request(t, s, "PUT", "/up012345678912", "some message", nil)
require.Equal(t, 429, rr.Code)
}
func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.VisitorRequestLimitBurst = 3
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// "Register" 5 different UnifiedPush visitors
@@ -2105,6 +2225,7 @@ func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// "Register" 5 different UnifiedPush visitors
@@ -2132,6 +2253,7 @@ func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
c := newTestConfig(t)
c.VisitorRequestLimitBurst = 3
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// "Register" rate visitor
@@ -2167,6 +2289,7 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionDenyAll
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// Create some ACLs
@@ -2214,6 +2337,7 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.AuthDefault = user.PermissionReadWrite
c.VisitorSubscriberRateLimiting = true
s := newTestServer(t, c)
// Create some ACLs
@@ -2333,5 +2457,5 @@ func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) {
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("Function f did not succeed after %v", maxWait)
t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack()))
}

View File

@@ -36,7 +36,10 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
if err != nil {
return err
}
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
var auth smtp.Auth
if s.config.SMTPSenderUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
}
ev := logvm(v, m).
Tag(tagEmail).
Fields(log.Context{

View File

@@ -2,8 +2,17 @@ package server
import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"math/rand"
"sync"
"time"
)
const (
// topicExpungeAfter defines how long a topic is active before it is removed from memory.
// This must be larger than matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter to give
// time for more requests to come in, so that we can send a {"rejected":["<pushkey>"]} response back.
topicExpungeAfter = 16 * time.Hour
)
// topic represents a channel to which subscribers can subscribe, and publishers
@@ -12,6 +21,7 @@ type topic struct {
ID string
subscribers map[int]*topicSubscriber
rateVisitor *visitor
lastAccess time.Time
mu sync.RWMutex
}
@@ -29,6 +39,7 @@ func newTopic(id string) *topic {
return &topic{
ID: id,
subscribers: make(map[int]*topicSubscriber),
lastAccess: time.Now(),
}
}
@@ -42,6 +53,7 @@ func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
subscriber: s,
cancel: cancel,
}
t.lastAccess = time.Now()
return subscriberID
}
@@ -51,13 +63,20 @@ func (t *topic) Stale() bool {
if t.rateVisitor != nil && !t.rateVisitor.Stale() {
return false
}
return len(t.subscribers) == 0
return len(t.subscribers) == 0 && time.Since(t.lastAccess) > topicExpungeAfter
}
func (t *topic) LastAccess() time.Time {
t.mu.RLock()
defer t.mu.RUnlock()
return t.lastAccess
}
func (t *topic) SetRateVisitor(v *visitor) {
t.mu.Lock()
defer t.mu.Unlock()
t.rateVisitor = v
t.lastAccess = time.Now()
}
func (t *topic) RateVisitor() *visitor {
@@ -96,15 +115,23 @@ func (t *topic) Publish(v *visitor, m *message) error {
} else {
logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding")
}
t.Keepalive()
}()
return nil
}
// SubscribersCount returns the number of subscribers to this topic
func (t *topic) SubscribersCount() int {
// Stats returns the number of subscribers and last access to this topic
func (t *topic) Stats() (int, time.Time) {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.subscribers)
return len(t.subscribers), t.lastAccess
}
// Keepalive sets the last access time and ensures that Stale does not return true
func (t *topic) Keepalive() {
t.mu.Lock()
defer t.mu.Unlock()
t.lastAccess = time.Now()
}
// CancelSubscribers calls the cancel function for all subscribers, forcing
@@ -131,6 +158,7 @@ func (t *topic) Context() log.Context {
fields := map[string]any{
"topic": t.ID,
"topic_subscribers": len(t.subscribers),
"topic_last_access": util.FormatTime(t.lastAccess),
}
if t.rateVisitor != nil {
for k, v := range t.rateVisitor.Context() {

View File

@@ -4,6 +4,7 @@ import (
"github.com/stretchr/testify/require"
"sync/atomic"
"testing"
"time"
)
func TestTopic_CancelSubscribers(t *testing.T) {
@@ -28,3 +29,13 @@ func TestTopic_CancelSubscribers(t *testing.T) {
require.True(t, canceled1.Load())
require.False(t, canceled2.Load())
}
func TestTopic_Keepalive(t *testing.T) {
t.Parallel()
to := newTopic("mytopic")
to.lastAccess = time.Now().Add(-1 * time.Hour)
to.Keepalive()
require.True(t, to.LastAccess().Unix() >= time.Now().Unix()-2)
require.True(t, to.LastAccess().Unix() <= time.Now().Unix()+2)
}

View File

@@ -107,8 +107,8 @@ func withContext(r *http.Request, ctx map[contextKey]any) *http.Request {
return r.WithContext(c)
}
func fromContext[T any](r *http.Request, key contextKey) *T {
t, ok := r.Context().Value(key).(*T)
func fromContext[T any](r *http.Request, key contextKey) T {
t, ok := r.Context().Value(key).(T)
if !ok {
panic(fmt.Sprintf("cannot find key %v in request context", key))
}

View File

@@ -143,6 +143,7 @@ func (v *visitor) contextNoLock() log.Context {
fields := log.Context{
"visitor_id": visitorID(v.ip, v.user),
"visitor_ip": v.ip.String(),
"visitor_seen": util.FormatTime(v.seen),
"visitor_messages": info.Stats.Messages,
"visitor_messages_limit": info.Limits.MessageLimit,
"visitor_messages_remaining": info.Stats.MessagesRemaining,

View File

@@ -14,6 +14,15 @@ var (
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
)
const (
timestampFormat = "2006-01-02T15:04:05.999Z07:00" // Like RFC3339, but with milliseconds
)
// FormatTime formats a time.Time in a RFC339-like format that includes milliseconds
func FormatTime(t time.Time) string {
return t.Format(timestampFormat)
}
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
// of that time from the current time (in UTC).
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {

38
web/package-lock.json generated
View File

@@ -3993,9 +3993,9 @@
"integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA=="
},
"node_modules/@types/node": {
"version": "18.14.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.2.tgz",
"integrity": "sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA=="
"version": "18.14.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.6.tgz",
"integrity": "sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA=="
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
@@ -5473,9 +5473,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001458",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz",
"integrity": "sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w==",
"version": "1.0.30001460",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001460.tgz",
"integrity": "sha512-Bud7abqjvEjipUkpLs4D7gR0l8hBYBHoa+tGtKJHvT2AYzLp1z7EmVkUT4ERpVUfca8S2HGIVs883D8pUH1ZzQ==",
"funding": [
{
"type": "opencollective",
@@ -6724,9 +6724,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.314",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.314.tgz",
"integrity": "sha512-+3RmNVx9hZLlc0gW//4yep0K5SYKmIvB5DXg1Yg6varsuAHlHwTeqeygfS8DWwLCsNOWrgj+p9qgM5WYjw1lXQ=="
"version": "1.4.320",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.320.tgz",
"integrity": "sha512-h70iRscrNluMZPVICXYl5SSB+rBKo22XfuIS1ER0OQxQZpKTnFpuS6coj7wY9M/3trv7OR88rRMOlKmRvDty7Q=="
},
"node_modules/emittery": {
"version": "0.8.1",
@@ -7641,9 +7641,9 @@
}
},
"node_modules/esquery": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.2.tgz",
"integrity": "sha512-JVSoLdTlTDkmjFmab7H/9SL9qGSyjElT3myyKp7krqjVFQCDLmj1QFaCLRFBszBKI0XVZaiiXvuPIX3ZwHe1Ng==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
"integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
"dependencies": {
"estraverse": "^5.1.0"
},
@@ -9072,12 +9072,12 @@
}
},
"node_modules/is-array-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.1.tgz",
"integrity": "sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz",
"integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==",
"dependencies": {
"call-bind": "^1.0.2",
"get-intrinsic": "^1.1.3",
"get-intrinsic": "^1.2.0",
"is-typed-array": "^1.1.10"
},
"funding": {
@@ -11733,9 +11733,9 @@
}
},
"node_modules/lilconfig": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz",
"integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"engines": {
"node": ">=10"
}

View File

@@ -39,7 +39,7 @@
"message_bar_type_message": "اكتب رسالة هنا",
"alert_not_supported_title": "الإشعارات غير مدعومة",
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
"message_bar_error_publishing": "خطأ خلال نشر الإشعار",
"notifications_delete": "حذف",
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
@@ -277,5 +277,11 @@
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
"action_bar_reservation_delete": "إزالة الحجز",
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر."
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.",
"prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.",
"notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على <websiteLink>موقع الويب</websiteLink> أو على <docsLink>الدليل</docsLink>.",
"publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى <docsLink>الدليل</docsLink>.",
"subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".",
"prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها",
"notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع."
}

View File

@@ -1 +1,225 @@
{}
{
"common_save": "Gem",
"common_add": "Tilføj",
"signup_title": "Opret en ntfy konto",
"signup_form_username": "Brugernavn",
"signup_form_password": "Kodeord",
"signup_form_confirm_password": "Bekræft kodeord",
"common_cancel": "Annuller",
"action_bar_account": "Konto",
"signup_error_username_taken": "Brugernavnet {{username}} er optaget",
"login_form_button_submit": "Log ind",
"action_bar_show_menu": "Vis menu",
"action_bar_logo_alt": "ntfy logo",
"action_bar_settings": "Indstillinger",
"signup_form_button_submit": "Opret konto",
"signup_form_toggle_password_visibility": "Skift synlighed af adgangskode",
"signup_disabled": "Tilmelding er deaktiveret",
"signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået",
"login_title": "Log ind på din ntfy konto",
"login_link_signup": "Opret konto",
"login_disabled": "Login er deaktiveret",
"action_bar_reservation_add": "Reserver emne",
"action_bar_reservation_edit": "Rediger reservation",
"action_bar_reservation_delete": "Fjern reservation",
"action_bar_reservation_limit_reached": "Grænsen er nået",
"action_bar_send_test_notification": "Send test notifikation",
"action_bar_unsubscribe": "Afmeld",
"action_bar_toggle_mute": "Slå lyden fra/til for notifikationer",
"action_bar_change_display_name": "Skift visningsnavn",
"action_bar_toggle_action_menu": "Åben/luk handlings menu",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Indstillinger",
"action_bar_profile_logout": "Log ud",
"action_bar_sign_in": "Log ind",
"action_bar_sign_up": "Opret konto",
"message_bar_type_message": "Skriv en besked her",
"nav_button_settings": "Indstillinger",
"message_bar_publish": "Offentliggør besked",
"nav_topics_title": "Tilmeldte emner",
"nav_button_all_notifications": "Alle notifikationer",
"nav_button_connecting": "forbinder",
"nav_upgrade_banner_label": "Opgrader til ntfy Pro",
"alert_grant_title": "Notifikationer er deaktiveret",
"alert_grant_description": "Giv din browser tilladelse til at vise skrivebordsnotifikationer.",
"alert_not_supported_title": "Notifikationer understøttes ikke",
"alert_not_supported_description": "Notifikationer understøttes ikke i din browser.",
"alert_not_supported_context_description": "Notifikationer understøttes kun via HTTPS. Dette skyldes en begrænsning i <mdnLink>Notifications API</mdnLink>.",
"nav_button_subscribe": "Abonner på emne",
"notifications_list_item": "Notifikation",
"notifications_delete": "Slet",
"notifications_tags": "Tags",
"notifications_list": "Notifikationsliste",
"notifications_mark_read": "Marker som læst",
"notifications_copied_to_clipboard": "Kopieret til udklipsholder",
"notifications_priority_x": "Prioritet {{priority}}",
"notifications_attachment_copy_url_title": "Kopier URL-adresse til vedhæftet fil til udklipsholder",
"notifications_attachment_copy_url_button": "Kopier URL",
"notifications_attachment_open_title": "Gå til {{url}}",
"notifications_attachment_open_button": "Åben vedhæftning",
"notifications_attachment_link_expires": "link udløber {{date}}",
"notifications_attachment_link_expired": "download link er udløbet",
"notifications_attachment_file_image": "billedfil",
"notifications_attachment_file_app": "Android app fil",
"notifications_attachment_file_document": "andet dokument",
"notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen",
"notifications_click_copy_url_button": "Kopier link",
"notifications_example": "Eksempel",
"notifications_click_open_button": "Åbn link",
"notifications_actions_not_supported": "Handlingen understøttes ikke i webappen",
"notifications_actions_http_request_title": "Send HTTP {{method}} til {{url}}",
"notifications_none_for_topic_title": "Du har ikke modtaget nogen notifikationer om dette emne endnu.",
"notifications_none_for_any_title": "Du har ikke modtaget nogen notifikationer.",
"display_name_dialog_placeholder": "Vist navn",
"publish_dialog_progress_uploading": "Uploader…",
"display_name_dialog_title": "Skift visningsnavn",
"publish_dialog_progress_uploading_detail": "Uploader {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_emoji_picker_show": "Vælg emoji",
"publish_dialog_priority_min": "Min. prioritet",
"publish_dialog_priority_low": "Lav prioritet",
"publish_dialog_priority_default": "Standardprioritet",
"publish_dialog_priority_high": "Høj prioritet",
"publish_dialog_title_label": "Titel",
"publish_dialog_message_label": "Besked",
"publish_dialog_tags_label": "Tags",
"publish_dialog_priority_label": "Prioritet",
"publish_dialog_message_placeholder": "Skriv en besked her",
"publish_dialog_tags_placeholder": "Komma-separeret liste over tags, f.eks. warning, srv1-backup",
"publish_dialog_click_label": "Klik på URL",
"publish_dialog_email_reset": "Fjern videresendelse af e-mail",
"publish_dialog_attach_placeholder": "Vedhæft fil via URL, f.eks. https://f-droid.org/F-Droid.apk",
"publish_dialog_delay_label": "Forsinkelse",
"publish_dialog_button_send": "Send",
"subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
"subscribe_dialog_login_button_back": "Tilbage",
"subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
"account_basics_title": "Konto",
"subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
"account_basics_username_admin_tooltip": "Du er Admin",
"account_basics_password_dialog_confirm_password_label": "Bekræft kodeord",
"account_basics_password_dialog_current_password_incorrect": "Forkert kodeord",
"account_usage_of_limit": "af {{limit}}",
"account_basics_tier_basic": "Grundlæggende",
"account_basics_tier_free": "Gratis",
"account_basics_tier_admin_suffix_no_tier": "(intet niveau)",
"account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} niveau)",
"account_usage_messages_title": "Offentliggjorte meddelelser",
"account_delete_dialog_button_submit": "Slet konto permanent",
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pr. fil",
"account_upgrade_dialog_button_redirect_signup": "Tilmeld dig nu",
"account_tokens_table_expires_header": "Udløber",
"account_tokens_table_last_access_header": "Seneste adgang",
"account_tokens_delete_dialog_title": "Slet adgangstoken",
"prefs_notifications_sound_no_sound": "Ingen lyd",
"prefs_notifications_min_priority_title": "Minimumsprioritet",
"prefs_notifications_sound_play": "Afspil den valgte lyd",
"prefs_notifications_min_priority_max_only": "Kun maks. prioritet",
"prefs_notifications_delete_after_three_hours": "Efter tre timer",
"prefs_users_add_button": "Tilføj bruger",
"prefs_users_dialog_title_edit": "Rediger bruger",
"prefs_reservations_title": "Reserverede emner",
"prefs_reservations_add_button": "Tilføj reserveret emne",
"prefs_reservations_table_access_header": "Adgang",
"prefs_reservations_delete_button": "Nulstil emneadgang",
"prefs_reservations_dialog_title_edit": "Rediger reserveret emne",
"prefs_reservations_dialog_access_label": "Adgang",
"prefs_reservations_dialog_title_delete": "Slet emnereservation",
"priority_low": "lav",
"priority_min": "min",
"reservation_delete_dialog_submit_button": "Slet reservation",
"priority_high": "høj",
"priority_max": "maks",
"error_boundary_stack_trace": "Strack trace",
"error_boundary_button_copy_stack_trace": "Kopier stack trace",
"signup_already_have_account": "Har du allerede en konto? Log ind!",
"action_bar_clear_notifications": "Ryd alle notifikationer",
"notifications_new_indicator": "Ny notifikation",
"notifications_attachment_image": "Vedhæftet billede",
"account_delete_dialog_label": "Kodeord",
"error_boundary_unsupported_indexeddb_title": "Privat browsing understøttes ikke",
"notifications_actions_open_url_title": "Gå til {{url}}",
"notifications_attachment_file_audio": "lydfil",
"publish_dialog_click_placeholder": "URL der åbnes, når der klikkes på notifikationen",
"publish_dialog_email_placeholder": "Adresse, som meddelelsen skal videresendes til, f.eks. phil@example.com",
"notifications_attachment_file_video": "videofil",
"account_basics_tier_title": "Kontotype",
"publish_dialog_filename_label": "Filnavn",
"account_basics_tier_manage_billing_button": "Administrer fakturering",
"account_usage_emails_title": "Afsendte e-mails",
"account_usage_reservations_title": "Reserverede emner",
"account_delete_title": "Slet konto",
"nav_button_account": "Konto",
"nav_button_documentation": "Dokumentation",
"publish_dialog_priority_max": "Maks. prioritet",
"account_upgrade_dialog_button_cancel_subscription": "Opsig abonnement",
"account_upgrade_dialog_button_update_subscription": "Opdater abonnement",
"publish_dialog_button_cancel": "Annuller",
"publish_dialog_email_label": "Email",
"account_tokens_title": "Adgangstokens",
"account_tokens_table_never_expires": "Udløber aldrig",
"prefs_notifications_sound_title": "Notifikationslyd",
"account_tokens_dialog_button_update": "Opdater token",
"account_tokens_dialog_button_create": "Opret token",
"subscribe_dialog_subscribe_button_cancel": "Annuller",
"prefs_users_table_user_header": "Bruger",
"prefs_appearance_title": "Udseende",
"subscribe_dialog_login_button_login": "Log ind",
"subscribe_dialog_login_password_label": "Kodeord",
"subscribe_dialog_error_user_anonymous": "anonym",
"account_usage_title": "Anvendelse",
"account_basics_username_title": "Brugernavn",
"account_basics_tier_admin": "Admin",
"account_basics_password_title": "Kodeord",
"account_upgrade_dialog_tier_selected_label": "Valgt",
"account_usage_unlimited": "Ubegrænset",
"account_tokens_table_label_header": "Label",
"account_tokens_dialog_button_cancel": "Annuller",
"account_basics_tier_change_button": "Rediger",
"account_delete_dialog_button_cancel": "Annuller",
"account_upgrade_dialog_button_cancel": "Annuller",
"account_tokens_table_token_header": "Token",
"account_upgrade_dialog_tier_current_label": "Nuværende",
"prefs_notifications_title": "Notifikationer",
"prefs_notifications_delete_after_never": "Aldrig",
"prefs_reservations_table_topic_header": "Emne",
"prefs_users_dialog_password_label": "Kodeord",
"prefs_appearance_language_title": "Sprog",
"prefs_reservations_dialog_topic_label": "Emne",
"priority_default": "standard",
"publish_dialog_attached_file_remove": "Fjern vedhæftet fil",
"prefs_users_table": "Bruger tabel",
"prefs_users_edit_button": "Rediger bruger",
"prefs_users_dialog_title_add": "Tilføj bruger",
"prefs_users_delete_button": "Slet bruger",
"account_tokens_table_copied_to_clipboard": "Adgangstoken kopieret",
"prefs_notifications_min_priority_any": "Enhver prioritet",
"prefs_notifications_delete_after_title": "Slet notifikationer",
"publish_dialog_delay_reset": "Fjern forsinket levering",
"prefs_users_title": "Administrer brugere",
"account_basics_password_dialog_button_submit": "Skift kodeord",
"prefs_reservations_dialog_title_add": "Reserver emne",
"account_basics_password_dialog_current_password_label": "Nuværende kodeord",
"account_basics_password_dialog_new_password_label": "Nyt kodeord",
"notifications_loading": "Indlæser notifikationer…",
"account_upgrade_dialog_tier_features_emails": "{{emails}} daglige e-mails",
"account_tokens_table_create_token_button": "Opret adgangstoken",
"account_tokens_dialog_title_delete": "Slet adgangstoken",
"publish_dialog_chip_email_label": "Videresend til e-mail",
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} samlet lagerplads",
"subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
"account_basics_tier_upgrade_button": "Opgrader til Pro",
"account_upgrade_dialog_tier_features_messages": "{{messages}} daglige beskeder",
"account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder",
"prefs_reservations_edit_button": "Rediger emneadgang",
"account_upgrade_dialog_title": "Skift kontoniveau",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserverede emner",
"account_tokens_dialog_expires_never": "Token udløber aldrig",
"account_tokens_table_current_session": "Nuværende browsersession",
"account_tokens_dialog_title_edit": "Rediger adgangstoken",
"account_tokens_dialog_title_create": "Opret adgangstoken",
"prefs_notifications_delete_after_one_day": "Efter en dag",
"account_tokens_delete_dialog_submit_button": "Slet token permanent",
"prefs_notifications_delete_after_one_month": "Efter en måned",
"prefs_notifications_delete_after_one_week": "Efter en uge",
"prefs_users_dialog_username_label": "Brugernavn, f.eks. phil"
}

View File

@@ -187,5 +187,53 @@
"prefs_notifications_delete_after_never": "Nigdy",
"prefs_users_dialog_title_edit": "Edytuj użytkownika",
"priority_min": "minimum",
"error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>."
"error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>.",
"signup_form_password": "Hasło",
"signup_title": "Załóż konto ntfy",
"signup_error_creation_limit_reached": "Przekroczono limit zakładania kont",
"action_bar_reservation_limit_reached": "Limit wyczerpany",
"display_name_dialog_title": "Zmień wyświetlaną nazwę",
"display_name_dialog_description": "Ustaw alternatywną nazwę dla tematu wyświetlanego na liście subskrybcji. To ułatwia identyfikację tematów o skomplikowanych nazwach.",
"account_basics_title": "Konto",
"account_basics_password_dialog_title": "Zmień hasło",
"signup_form_username": "Nawa użytkownika",
"signup_form_confirm_password": "Powtórz hasło",
"signup_form_button_submit": "Załóż konto",
"signup_form_toggle_password_visibility": "Pokaż lub ukryj hasło",
"signup_already_have_account": "Masz już konto? Zaloguj się!",
"signup_disabled": "Zakładanie kont jest wyłączone",
"signup_error_username_taken": "Nazwa użytkownika {{username}} jest już zajęta",
"login_title": "Zaloguj się do swojego konta ntfy",
"login_form_button_submit": "Zaloguj się",
"login_link_signup": "Załóż konto",
"login_disabled": "Logowanie jet wyłączone",
"action_bar_account": "Konto",
"action_bar_change_display_name": "Zmień wyświetlaną nazwę",
"action_bar_reservation_add": "Zarezerwuj temat",
"action_bar_reservation_edit": "Zmień rezerwację",
"action_bar_reservation_delete": "Usuń rezerwację",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Ustawienia",
"action_bar_profile_logout": "Wyloguj",
"action_bar_sign_in": "Zaloguj",
"action_bar_sign_up": "Załóż konto",
"nav_button_account": "Konto",
"display_name_dialog_placeholder": "Nazwa wyświetlana",
"reserve_dialog_checkbox_label": "Zarezerwuj temat i skonfiguruj dostęp",
"subscribe_dialog_subscribe_button_generate_topic_name": "Wygeneruj nazwę",
"subscribe_dialog_error_topic_already_reserved": "Temat już jest zarezerwowany",
"account_basics_username_title": "Nazwa użytkownika",
"account_basics_username_description": "Hej, to Ty ❤",
"account_basics_username_admin_tooltip": "Jesteś Administratorem",
"account_basics_password_title": "Hasło",
"account_basics_password_description": "Zmień hasło do konta",
"account_basics_password_dialog_current_password_label": "Aktualne hasło",
"account_basics_password_dialog_new_password_label": "Nowe hasło",
"account_basics_password_dialog_confirm_password_label": "Powtórz hasło",
"account_basics_password_dialog_button_submit": "Zmień hasło",
"account_basics_password_dialog_current_password_incorrect": "Błędne hasło",
"account_usage_title": "Użycie",
"account_usage_of_limit": "z {{limit}}",
"account_usage_unlimited": "Bez limitu",
"account_usage_limits_reset_daily": "Limity są resetowane codziennie o północy (UTC)"
}

View File

@@ -461,6 +461,7 @@ const Language = () => {
<MenuItem value="bg">Български</MenuItem>
<MenuItem value="cs">Čeština</MenuItem>
<MenuItem value="zh_Hans">中文</MenuItem>
<MenuItem value="da">Dansk</MenuItem>
<MenuItem value="de">Deutsch</MenuItem>
<MenuItem value="es">Español</MenuItem>
<MenuItem value="fr">Français</MenuItem>