Merge branch 'enable-subscriber-rate-limiting' into matrix-507-reject
commit
3eeeac2c13
|
@ -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. -->
|
|
@ -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 -->
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
-->
|
|
@ -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 :) -->
|
10
Dockerfile
10
Dockerfile
|
@ -1,5 +1,13 @@
|
||||||
FROM alpine
|
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
|
COPY ntfy /usr/bin
|
||||||
|
|
||||||
|
|
|
@ -13,9 +13,14 @@
|
||||||
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
|
[![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)
|
[![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>
|
<p>
|
||||||
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||||
|
|
|
@ -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`).
|
|
@ -171,7 +171,7 @@ func execPublish(c *cli.Context) error {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||||
}
|
}
|
||||||
options = append(options, client.WithBasicAuth(user, pass))
|
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))
|
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
||||||
}
|
}
|
||||||
if pid > 0 {
|
if pid > 0 {
|
||||||
|
|
|
@ -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-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.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.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{"enable_rate_visitor"}, EnvVars: []string{"NTFY_ENABLE_RATE_VISITOR"}, Value: false, Usage: "enables subscriber-based rate limiting for UnifiedPush topics"}),
|
||||||
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.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-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"}),
|
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")
|
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||||
totalTopicLimit := c.Int("global-topic-limit")
|
totalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
|
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||||
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
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")
|
return errors.New("if set, certificate file must exist")
|
||||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||||
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
} else if smtpSenderAddr != "" && (baseURL == "" || 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")
|
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
|
||||||
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
||||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||||
|
@ -304,6 +306,7 @@ func execServe(c *cli.Context) error {
|
||||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
|
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
conf.StripeSecretKey = stripeSecretKey
|
conf.StripeSecretKey = stripeSecretKey
|
||||||
conf.StripeWebhookKey = stripeWebhookKey
|
conf.StripeWebhookKey = stripeWebhookKey
|
||||||
|
|
|
@ -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
|
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
|
## 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 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**.
|
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-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-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-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) |
|
| `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-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 |
|
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||||
|
|
|
@ -26,37 +26,37 @@ deb/rpm packages.
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_x86_64.tar.gz
|
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.0_linux_x86_64.tar.gz
|
tar zxvf ntfy_2.1.1_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_2.1.0_linux_x86_64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.1.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.1.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.1.1_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.1.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.1.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.1.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.1.1_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.1.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.1.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.1.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.1.1_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.1.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.1.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -106,7 +106,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -114,7 +114,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -122,7 +122,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -130,7 +130,7 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
|
@ -140,28 +140,28 @@ Manually installing the .deb file:
|
||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
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.0/ntfy_2.1.0_macOS_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_macOS_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
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
|
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).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_macOS_all.tar.gz > ntfy_2.1.0_macOS_all.tar.gz
|
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.0_macOS_all.tar.gz
|
tar zxvf ntfy_2.1.1_macOS_all.tar.gz
|
||||||
sudo cp -a ntfy_2.1.0_macOS_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.1.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.1.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.1.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -212,7 +212,7 @@ ntfy --help
|
||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
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.0/ntfy_2.1.0_windows_x86_64.zip),
|
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),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
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).
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
|
|
|
@ -2,7 +2,21 @@
|
||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
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).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
## ntfy server v2.1.1 (UNRELEASED)
|
## ntfy server v2.2.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**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))
|
||||||
|
|
||||||
|
**Additional languages:**
|
||||||
|
|
||||||
|
* Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/))
|
||||||
|
|
||||||
|
## ntfy server v2.1.1
|
||||||
Released March 1, 2023
|
Released March 1, 2023
|
||||||
|
|
||||||
This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,
|
This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -19,7 +19,7 @@ require (
|
||||||
golang.org/x/sync v0.1.0
|
golang.org/x/sync v0.1.0
|
||||||
golang.org/x/term v0.5.0
|
golang.org/x/term v0.5.0
|
||||||
golang.org/x/time v0.3.0
|
golang.org/x/time v0.3.0
|
||||||
google.golang.org/api v0.110.0
|
google.golang.org/api v0.111.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -165,8 +165,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
|
google.golang.org/api v0.111.0 h1:bwKi+z2BsdwYFRKrqwutM+axAlYLz83gt5pDSXCJT+0=
|
||||||
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
|
|
14
log/event.go
14
log/event.go
|
@ -3,6 +3,7 @@ package log
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -11,12 +12,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fieldTag = "tag"
|
fieldTag = "tag"
|
||||||
fieldError = "error"
|
fieldError = "error"
|
||||||
fieldTimeTaken = "time_taken_ms"
|
fieldTimeTaken = "time_taken_ms"
|
||||||
fieldExitCode = "exit_code"
|
fieldExitCode = "exit_code"
|
||||||
tagStdLog = "stdlog"
|
tagStdLog = "stdlog"
|
||||||
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a single log event
|
// 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.Message = fmt.Sprintf(message, v...)
|
||||||
e.Level = l
|
e.Level = l
|
||||||
e.Timestamp = e.time.Format(timestampFormat)
|
e.Timestamp = util.FormatTime(e.time)
|
||||||
if !appliedContexters {
|
if !appliedContexters {
|
||||||
e.applyContexters()
|
e.applyContexters()
|
||||||
}
|
}
|
||||||
|
|
|
@ -124,6 +124,7 @@ type Config struct {
|
||||||
VisitorAuthFailureLimitBurst int
|
VisitorAuthFailureLimitBurst int
|
||||||
VisitorAuthFailureLimitReplenish time.Duration
|
VisitorAuthFailureLimitReplenish time.Duration
|
||||||
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
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
|
BehindProxy bool
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
|
@ -198,10 +199,12 @@ func NewConfig() *Config {
|
||||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
|
VisitorSubscriberRateLimiting: false,
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
StripeSecretKey: "",
|
StripeSecretKey: "",
|
||||||
StripeWebhookKey: "",
|
StripeWebhookKey: "",
|
||||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
|
BillingContact: "",
|
||||||
EnableWeb: true,
|
EnableWeb: true,
|
||||||
EnableSignup: false,
|
EnableSignup: false,
|
||||||
EnableLogin: false,
|
EnableLogin: false,
|
||||||
|
|
|
@ -31,7 +31,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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}
|
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -597,7 +597,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e.With(t)
|
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
|
// 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
|
// 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.
|
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
|
||||||
|
@ -1197,14 +1197,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
|
// 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.
|
// 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)
|
// - auth-file is not set (everything is open by default)
|
||||||
// - the topic is reserved, and v.user is the owner
|
// - or 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 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
|
// 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.
|
// until the Android app will send the "Rate-Topics" header.
|
||||||
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
|
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
|
// Make a list of topics that we'll actually set the RateVisitor on
|
||||||
eligibleRateTopics := make([]*topic, 0)
|
eligibleRateTopics := make([]*topic, 0)
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
|
|
|
@ -117,18 +117,19 @@
|
||||||
# attachment-expiry-duration: "3h"
|
# attachment-expiry-duration: "3h"
|
||||||
|
|
||||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
# 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
|
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||||
# 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).
|
# 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-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-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-addr:
|
||||||
|
# smtp-sender-from:
|
||||||
# smtp-sender-user:
|
# smtp-sender-user:
|
||||||
# smtp-sender-pass:
|
# smtp-sender-pass:
|
||||||
# smtp-sender-from:
|
|
||||||
|
|
||||||
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
|
# 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.
|
# 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-total-size-limit: "100M"
|
||||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
# 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
|
# Payments integration via Stripe
|
||||||
#
|
#
|
||||||
# - stripe-secret-key is the key used for the Stripe API communication. Setting this values
|
# - stripe-secret-key is the key used for the Stripe API communication. Setting this values
|
||||||
|
|
|
@ -657,6 +657,17 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||||
m2 := toMessage(t, rr.Body.String())
|
m2 := toMessage(t, rr.Body.String())
|
||||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||||
|
|
||||||
|
// Pre-verify message count and file
|
||||||
|
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(ms))
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||||
|
|
||||||
|
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(ms))
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||||
|
|
||||||
// Delete reservation
|
// Delete reservation
|
||||||
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
|
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
|
||||||
"X-Delete-Messages": "true",
|
"X-Delete-Messages": "true",
|
||||||
|
@ -672,9 +683,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||||
|
|
||||||
// Verify that messages and attachments were deleted
|
// Verify that messages and attachments were deleted
|
||||||
// This does not explicitly call the manager!
|
// This does not explicitly call the manager!
|
||||||
time.Sleep(time.Second)
|
waitFor(t, func() bool {
|
||||||
|
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||||
|
})
|
||||||
|
|
||||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 0, len(ms))
|
require.Equal(t, 0, len(ms))
|
||||||
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||||
|
@ -712,13 +727,12 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
// Wait for stats queue writer
|
// Wait for stats queue writer, verify that message stats were persisted
|
||||||
time.Sleep(600 * time.Millisecond)
|
waitFor(t, func() bool {
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
// Verify that message stats were persisted
|
require.Nil(t, err)
|
||||||
u, err := s.userManager.User("phil")
|
return int64(1) == u.Stats.Messages
|
||||||
require.Nil(t, err)
|
})
|
||||||
require.Equal(t, int64(1), u.Stats.Messages)
|
|
||||||
|
|
||||||
// Change tier, make a request (to reset limiters)
|
// Change tier, make a request (to reset limiters)
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
@ -736,10 +750,11 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
// Verify that message stats were persisted
|
// Verify that message stats were persisted
|
||||||
time.Sleep(600 * time.Millisecond)
|
waitFor(t, func() bool {
|
||||||
u, err = s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
|
return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run!
|
||||||
|
})
|
||||||
|
|
||||||
// Stats keep counting
|
// Stats keep counting
|
||||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -914,7 +915,15 @@ func TestServer_StatsResetter(t *testing.T) {
|
||||||
require.Equal(t, int64(2), account.Stats.Messages)
|
require.Equal(t, int64(2), account.Stats.Messages)
|
||||||
|
|
||||||
// Wait for stats resetter to run
|
// Wait for stats resetter to run
|
||||||
time.Sleep(2200 * time.Millisecond)
|
waitFor(t, func() bool {
|
||||||
|
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
return account.Stats.Messages == 0
|
||||||
|
})
|
||||||
|
|
||||||
// User stats show 0 messages now!
|
// User stats show 0 messages now!
|
||||||
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
@ -1283,7 +1292,9 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_MatrixGateway_Push_Failure_NoSubscriber(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"}]}}`
|
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||||
require.Equal(t, 507, response.Code)
|
require.Equal(t, 507, response.Code)
|
||||||
|
@ -1661,9 +1672,10 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||||
require.Equal(t, content, response.Body.String())
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
// Prune and makes sure it's gone
|
// Prune and makes sure it's gone
|
||||||
time.Sleep(time.Second) // Sigh ...
|
waitFor(t, func() bool {
|
||||||
s.execManager()
|
s.execManager() // May run many times
|
||||||
require.NoFileExists(t, file)
|
return !util.FileExists(file)
|
||||||
|
})
|
||||||
response = request(t, s, "GET", path, "", nil)
|
response = request(t, s, "GET", path, "", nil)
|
||||||
require.Equal(t, 404, response.Code)
|
require.Equal(t, 404, response.Code)
|
||||||
}
|
}
|
||||||
|
@ -2020,6 +2032,7 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
|
||||||
func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||||
c := newTestConfigWithAuthFile(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.VisitorRequestLimitBurst = 3
|
c.VisitorRequestLimitBurst = 3
|
||||||
|
c.VisitorSubscriberRateLimiting = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor
|
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor
|
||||||
|
@ -2031,6 +2044,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||||
}, subscriber1Fn)
|
}, subscriber1Fn)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Equal(t, "", rr.Body.String())
|
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)
|
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
|
||||||
subscriber2Fn := func(r *http.Request) {
|
subscriber2Fn := func(r *http.Request) {
|
||||||
|
@ -2039,6 +2053,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||||
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
|
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Equal(t, "", rr.Body.String())
|
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
|
// 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.
|
// GET request before is also counted towards the request limiter.
|
||||||
|
@ -2070,9 +2085,47 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||||
require.Equal(t, 429, rr.Code)
|
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) {
|
func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||||
c := newTestConfigWithAuthFile(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.VisitorRequestLimitBurst = 3
|
c.VisitorRequestLimitBurst = 3
|
||||||
|
c.VisitorSubscriberRateLimiting = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// "Register" 5 different UnifiedPush visitors
|
// "Register" 5 different UnifiedPush visitors
|
||||||
|
@ -2096,6 +2149,7 @@ func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||||
func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 3
|
c.VisitorRequestLimitBurst = 3
|
||||||
|
c.VisitorSubscriberRateLimiting = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// "Register" 5 different UnifiedPush visitors
|
// "Register" 5 different UnifiedPush visitors
|
||||||
|
@ -2123,6 +2177,7 @@ func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||||
func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 3
|
c.VisitorRequestLimitBurst = 3
|
||||||
|
c.VisitorSubscriberRateLimiting = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// "Register" rate visitor
|
// "Register" rate visitor
|
||||||
|
@ -2158,6 +2213,7 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
||||||
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
|
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
|
||||||
c := newTestConfigWithAuthFile(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthDefault = user.PermissionDenyAll
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
|
c.VisitorSubscriberRateLimiting = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// Create some ACLs
|
// Create some ACLs
|
||||||
|
@ -2205,6 +2261,7 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
|
||||||
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
|
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
|
||||||
c := newTestConfigWithAuthFile(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthDefault = user.PermissionReadWrite
|
c.AuthDefault = user.PermissionReadWrite
|
||||||
|
c.VisitorSubscriberRateLimiting = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// Create some ACLs
|
// Create some ACLs
|
||||||
|
@ -2311,3 +2368,18 @@ func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, f func() bool) {
|
||||||
|
waitForWithMaxWait(t, 5*time.Second, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) {
|
||||||
|
start := time.Now()
|
||||||
|
for time.Since(start) < maxWait {
|
||||||
|
if f() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack()))
|
||||||
|
}
|
||||||
|
|
|
@ -36,7 +36,10 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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).
|
ev := logvm(v, m).
|
||||||
Tag(tagEmail).
|
Tag(tagEmail).
|
||||||
Fields(log.Context{
|
Fields(log.Context{
|
||||||
|
|
|
@ -143,6 +143,7 @@ func (v *visitor) contextNoLock() log.Context {
|
||||||
fields := log.Context{
|
fields := log.Context{
|
||||||
"visitor_id": visitorID(v.ip, v.user),
|
"visitor_id": visitorID(v.ip, v.user),
|
||||||
"visitor_ip": v.ip.String(),
|
"visitor_ip": v.ip.String(),
|
||||||
|
"visitor_seen": util.FormatTime(v.seen),
|
||||||
"visitor_messages": info.Stats.Messages,
|
"visitor_messages": info.Stats.Messages,
|
||||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||||
|
|
|
@ -14,6 +14,15 @@ var (
|
||||||
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
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
|
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
|
||||||
// of that time from the current time (in UTC).
|
// of that time from the current time (in UTC).
|
||||||
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
|
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
"message_bar_type_message": "اكتب رسالة هنا",
|
"message_bar_type_message": "اكتب رسالة هنا",
|
||||||
"alert_not_supported_title": "الإشعارات غير مدعومة",
|
"alert_not_supported_title": "الإشعارات غير مدعومة",
|
||||||
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
|
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
|
||||||
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
|
"message_bar_error_publishing": "خطأ خلال نشر الإشعار",
|
||||||
"notifications_delete": "حذف",
|
"notifications_delete": "حذف",
|
||||||
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
|
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
|
||||||
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
|
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
|
||||||
|
@ -277,5 +277,11 @@
|
||||||
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
|
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
|
||||||
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
|
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
|
||||||
"action_bar_reservation_delete": "إزالة الحجز",
|
"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 الخاص بالموضوع."
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
@ -187,5 +187,53 @@
|
||||||
"prefs_notifications_delete_after_never": "Nigdy",
|
"prefs_notifications_delete_after_never": "Nigdy",
|
||||||
"prefs_users_dialog_title_edit": "Edytuj użytkownika",
|
"prefs_users_dialog_title_edit": "Edytuj użytkownika",
|
||||||
"priority_min": "minimum",
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
@ -461,6 +461,7 @@ const Language = () => {
|
||||||
<MenuItem value="bg">Български</MenuItem>
|
<MenuItem value="bg">Български</MenuItem>
|
||||||
<MenuItem value="cs">Čeština</MenuItem>
|
<MenuItem value="cs">Čeština</MenuItem>
|
||||||
<MenuItem value="zh_Hans">中文</MenuItem>
|
<MenuItem value="zh_Hans">中文</MenuItem>
|
||||||
|
<MenuItem value="da">Dansk</MenuItem>
|
||||||
<MenuItem value="de">Deutsch</MenuItem>
|
<MenuItem value="de">Deutsch</MenuItem>
|
||||||
<MenuItem value="es">Español</MenuItem>
|
<MenuItem value="es">Español</MenuItem>
|
||||||
<MenuItem value="fr">Français</MenuItem>
|
<MenuItem value="fr">Français</MenuItem>
|
||||||
|
|
Loading…
Reference in New Issue