Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477c9d3ed5 | ||
|
|
e44f0ef6e7 | ||
|
|
6f4b260035 | ||
|
|
bb7a751e58 | ||
|
|
97c9266cc8 | ||
|
|
a139a3df89 | ||
|
|
346d8d7967 | ||
|
|
3eeeac2c13 | ||
|
|
94f6d2d5b5 | ||
|
|
1c4420bca8 | ||
|
|
ecff7258ba | ||
|
|
72d4f67524 | ||
|
|
1ce92714c4 | ||
|
|
1c6c2cf332 | ||
|
|
9d42ee9391 | ||
|
|
b62204054f | ||
|
|
166dc6b4fa | ||
|
|
02a1e99db2 | ||
|
|
250637cf92 | ||
|
|
b46de7402d | ||
|
|
9334a94886 | ||
|
|
9b9aa4306a | ||
|
|
90db1283dd | ||
|
|
8cc00a6ac6 | ||
|
|
315034c8cd | ||
|
|
23ac9d44a1 | ||
|
|
70db2f994c | ||
|
|
64b3c3c2fa | ||
|
|
983afb2b45 | ||
|
|
4d22ccc7f6 | ||
|
|
cd3429842b | ||
|
|
d89df315e4 | ||
|
|
fe3a225f8f | ||
|
|
f862341997 | ||
|
|
8ca08ce868 | ||
|
|
ba46630138 | ||
|
|
a3087047b6 | ||
|
|
217ca81b17 | ||
|
|
7edcebad1f | ||
|
|
0af3e29ce1 | ||
|
|
dd6462de13 | ||
|
|
52f18d048c | ||
|
|
c522ee1dd8 | ||
|
|
33e3f7ae46 | ||
|
|
87f9f88e32 | ||
|
|
0fe1e109ed | ||
|
|
90b04417cf | ||
|
|
221004af39 | ||
|
|
c3f6077f95 | ||
|
|
4f9227f100 | ||
|
|
ae6f649a06 | ||
|
|
26f9eddfc4 | ||
|
|
00879d11d3 |
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
Normal 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. -->
|
||||
26
.github/ISSUE_TEMPLATE/2_enhancement_request.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/2_enhancement_request.md
vendored
Normal 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 -->
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/3_tech_support.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/3_tech_support.md
vendored
Normal 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
21
.github/ISSUE_TEMPLATE/4_question.md
vendored
Normal 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 :) -->
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,9 +1,15 @@
|
||||
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
|
||||
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD wget -q --tries=1 http://localhost/v1/health -O - | grep -Eo '"healthy"\s*:\s*true' || exit 1
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
11
README.md
11
README.md
@@ -13,9 +13,14 @@
|
||||
[](https://ntfy.statuspage.io/)
|
||||
[](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">
|
||||
@@ -119,6 +124,8 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
||||
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
|
||||
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
|
||||
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
|
||||
<a href="https://github.com/0xAF"><img src="https://github.com/0xAF.png" width="40px" /></a>
|
||||
<a href="https://github.com/soonoo"><img src="https://github.com/soonoo.png" width="40px" /></a>
|
||||
|
||||
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
||||
|
||||
10
SECURITY.md
Normal file
10
SECURITY.md
Normal 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`).
|
||||
@@ -40,7 +40,6 @@ var flagsPublish = append(
|
||||
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"env_topic", "P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
|
||||
)
|
||||
|
||||
@@ -172,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 {
|
||||
|
||||
@@ -86,7 +86,6 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, port := test.StartServer(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||
@@ -135,7 +134,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
|
||||
// Test: Successful command with NTFY_TOPIC
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
|
||||
@@ -144,7 +143,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
require.Nil(t, sleep.Start())
|
||||
go sleep.Wait() // Must be called to release resources
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
|
||||
}
|
||||
|
||||
10
cmd/serve.go
10
cmd/serve.go
@@ -81,9 +81,11 @@ 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"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
|
||||
)
|
||||
|
||||
var cmdServe = &cli.Command{
|
||||
@@ -148,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")
|
||||
@@ -159,6 +162,7 @@ func execServe(c *cli.Context) error {
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
stripeSecretKey := c.String("stripe-secret-key")
|
||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||
billingContact := c.String("billing-contact")
|
||||
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
@@ -175,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 == "" {
|
||||
@@ -302,9 +306,11 @@ 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
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableWeb = enableWeb
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
|
||||
@@ -22,7 +22,6 @@ func init() {
|
||||
}
|
||||
|
||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||
t.Parallel()
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
go func() {
|
||||
|
||||
35
cmd/tier.go
35
cmd/tier.go
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -17,12 +16,12 @@ func init() {
|
||||
|
||||
const (
|
||||
defaultMessageLimit = 5000
|
||||
defaultMessageExpiryDuration = 12 * time.Hour
|
||||
defaultMessageExpiryDuration = "12h"
|
||||
defaultEmailLimit = 20
|
||||
defaultReservationLimit = 3
|
||||
defaultAttachmentFileSizeLimit = "15M"
|
||||
defaultAttachmentTotalSizeLimit = "100M"
|
||||
defaultAttachmentExpiryDuration = 6 * time.Hour
|
||||
defaultAttachmentExpiryDuration = "6h"
|
||||
defaultAttachmentBandwidthLimit = "1G"
|
||||
)
|
||||
|
||||
@@ -47,12 +46,12 @@ var cmdTier = &cli.Command{
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
||||
&cli.DurationFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
||||
&cli.DurationFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
@@ -90,12 +89,12 @@ Examples:
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
||||
&cli.DurationFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
||||
&cli.DurationFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
@@ -189,6 +188,10 @@ func execTierAdd(c *cli.Context) error {
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -201,17 +204,21 @@ func execTierAdd(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tier := &user.Tier{
|
||||
ID: "", // Generated
|
||||
Code: code,
|
||||
Name: name,
|
||||
MessageLimit: c.Int64("message-limit"),
|
||||
MessageExpiryDuration: c.Duration("message-expiry-duration"),
|
||||
MessageExpiryDuration: messageExpiryDuration,
|
||||
EmailLimit: c.Int64("email-limit"),
|
||||
ReservationLimit: c.Int64("reservation-limit"),
|
||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||
AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"),
|
||||
AttachmentExpiryDuration: attachmentExpiryDuration,
|
||||
AttachmentBandwidthLimit: attachmentBandwidthLimit,
|
||||
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
|
||||
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
|
||||
@@ -252,7 +259,10 @@ func execTierChange(c *cli.Context) error {
|
||||
tier.MessageLimit = c.Int64("message-limit")
|
||||
}
|
||||
if c.IsSet("message-expiry-duration") {
|
||||
tier.MessageExpiryDuration = c.Duration("message-expiry-duration")
|
||||
tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("email-limit") {
|
||||
tier.EmailLimit = c.Int64("email-limit")
|
||||
@@ -273,7 +283,10 @@ func execTierChange(c *cli.Context) error {
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-expiry-duration") {
|
||||
tier.AttachmentExpiryDuration = c.Duration("attachment-expiry-duration")
|
||||
tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-bandwidth-limit") {
|
||||
tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit"))
|
||||
|
||||
@@ -29,11 +29,11 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=99h",
|
||||
"--message-expiry-duration=2d",
|
||||
"--email-limit=91",
|
||||
"--reservation-limit=98",
|
||||
"--attachment-file-size-limit=100m",
|
||||
"--attachment-expiry-duration=7h",
|
||||
"--attachment-expiry-duration=1d",
|
||||
"--attachment-total-size-limit=10G",
|
||||
"--attachment-bandwidth-limit=100G",
|
||||
"--stripe-monthly-price-id=price_991",
|
||||
@@ -41,11 +41,11 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
"pro",
|
||||
))
|
||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 99h")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 7h")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
|
||||
|
||||
@@ -839,6 +839,8 @@ config options:
|
||||
enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys).
|
||||
* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe.
|
||||
Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks).
|
||||
* `billing-contact` is an email address or website displayed in the "Upgrade tier" dialog to let people reach
|
||||
out with billing questions. If unset, nothing will be displayed.
|
||||
|
||||
In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks)
|
||||
for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points
|
||||
@@ -849,6 +851,7 @@ Here's an example:
|
||||
``` yaml
|
||||
stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG"
|
||||
stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
|
||||
billing-contact: "phil@example.com"
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
@@ -929,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**.
|
||||
@@ -1067,6 +1089,16 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
||||
maxretry = 10
|
||||
```
|
||||
|
||||
## Health checks
|
||||
A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.
|
||||
If a non-200 HTTP status code is returned or if the returned `health` field is `false` the ntfy service should be considered as unhealthy.
|
||||
|
||||
```json
|
||||
{"health":true}
|
||||
```
|
||||
|
||||
See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment.
|
||||
|
||||
## Logging & debugging
|
||||
By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.
|
||||
|
||||
@@ -1178,12 +1210,14 @@ 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 |
|
||||
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||
|
||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||
@@ -1267,6 +1301,7 @@ OPTIONS:
|
||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
|
||||
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
|
||||
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
|
||||
--help, -h show help (default: false)
|
||||
```
|
||||
|
||||
|
||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_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
|
||||
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.0/ntfy_2.1.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_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.0/ntfy_2.1.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_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.0/ntfy_2.1.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_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.0/ntfy_2.1.0_macOS_all.tar.gz > ntfy_2.1.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.1.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_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.0_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.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.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).
|
||||
@@ -266,7 +266,7 @@ docker run \
|
||||
serve
|
||||
```
|
||||
|
||||
Using docker-compose with non-root user:
|
||||
Using docker-compose with non-root user and healthchecks enabled:
|
||||
```yaml
|
||||
version: "2.1"
|
||||
|
||||
@@ -284,6 +284,12 @@ services:
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
ports:
|
||||
- 80:80
|
||||
healthcheck: # optional: remember to adapt the host:port to your environment
|
||||
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
|
||||
@@ -3161,16 +3161,20 @@ There are a few limitations to the API to prevent abuse and to keep the server h
|
||||
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
|
||||
but just in case, let's list them all:
|
||||
|
||||
| Limit | Description |
|
||||
|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. |
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
| Limit | Description |
|
||||
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 1,000. |
|
||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 10. |
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 5 MB, and the per-visitor total is 50 MB. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
|
||||
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
||||
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
|
||||
|
||||
## List of all parameters
|
||||
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
|
||||
|
||||
@@ -2,6 +2,53 @@
|
||||
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
|
||||
|
||||
This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,
|
||||
**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉.
|
||||
|
||||
You are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...),
|
||||
as well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the
|
||||
ntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use
|
||||
promo code `MYTOPIC` for a **50% discount**, limited time only).
|
||||
|
||||
And as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There
|
||||
are no closed-source features. So if you'd like to run your own server, you can!
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting)
|
||||
* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha))
|
||||
* Upgrade dialog: Disable submit button for free tier (no ticket)
|
||||
* Allow multiple `log-level-overrides` on the same field (no ticket)
|
||||
* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket)
|
||||
* Added `billing-contact` config option (no ticket)
|
||||
|
||||
## ntfy server v2.1.0
|
||||
Released February 25, 2023
|
||||
|
||||
@@ -16,6 +63,10 @@ which ntfy rejected with an HTTP 401. We now ignore unsupported header values.
|
||||
As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing
|
||||
cycles (not live yet).
|
||||
|
||||
As part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate
|
||||
limits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits
|
||||
a bit more. For 90% of users, you should not feel the difference.
|
||||
|
||||
**Features:**
|
||||
|
||||
* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm))
|
||||
|
||||
10
go.mod
10
go.mod
@@ -19,7 +19,7 @@ require (
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/term v0.5.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
|
||||
)
|
||||
|
||||
@@ -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-20230223222841-637eb2293923 // 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
|
||||
|
||||
27
go.sum
27
go.sum
@@ -42,8 +42,6 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
|
||||
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -103,10 +101,10 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stripe/stripe-go/v74 v74.8.0 h1:0+3EfQSBhMg8SQ1+w+AP6Gxyko2crWbUG2uXbzYs8SU=
|
||||
github.com/stripe/stripe-go/v74 v74.8.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||
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=
|
||||
@@ -129,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=
|
||||
@@ -146,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=
|
||||
@@ -169,8 +176,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-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||
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.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
||||
google.golang.org/api v0.111.0 h1:bwKi+z2BsdwYFRKrqwutM+axAlYLz83gt5pDSXCJT+0=
|
||||
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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
@@ -180,10 +187,10 @@ google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4Ho
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 h1:EfLuoKW5WfkgVdDy7dTK8qSbH37AX5mj/MFh+bGPz14=
|
||||
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 h1:znp6mq/drrY+6khTAlJUDNFFcDGV2ENLYKpMq8SyCds=
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw=
|
||||
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=
|
||||
|
||||
22
log/event.go
22
log/event.go
@@ -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()
|
||||
}
|
||||
@@ -210,11 +210,13 @@ func (e *Event) globalLevelWithOverride() Level {
|
||||
if e.fields == nil {
|
||||
return l
|
||||
}
|
||||
for field, override := range ov {
|
||||
for field, fieldOverrides := range ov {
|
||||
value, exists := e.fields[field]
|
||||
if exists {
|
||||
if override.value == "" || override.value == value || override.value == fmt.Sprintf("%v", value) {
|
||||
return override.level
|
||||
for _, o := range fieldOverrides {
|
||||
if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) {
|
||||
return o.level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ var (
|
||||
var (
|
||||
level = DefaultLevel
|
||||
format = DefaultFormat
|
||||
overrides = make(map[string]*levelOverride)
|
||||
overrides = make(map[string][]*levelOverride)
|
||||
output io.Writer = DefaultOutput
|
||||
filename = ""
|
||||
mu = &sync.RWMutex{}
|
||||
@@ -111,14 +111,17 @@ func SetLevel(newLevel Level) {
|
||||
func SetLevelOverride(field string, value string, level Level) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
overrides[field] = &levelOverride{value: value, level: level}
|
||||
if _, ok := overrides[field]; !ok {
|
||||
overrides[field] = make([]*levelOverride, 0)
|
||||
}
|
||||
overrides[field] = append(overrides[field], &levelOverride{value: value, level: level})
|
||||
}
|
||||
|
||||
// ResetLevelOverrides removes all log level overrides
|
||||
func ResetLevelOverrides() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
overrides = make(map[string]*levelOverride)
|
||||
overrides = make(map[string][]*levelOverride)
|
||||
}
|
||||
|
||||
// CurrentFormat returns the current log format
|
||||
|
||||
@@ -177,6 +177,27 @@ func TestLog_LevelOverrideAny(t *testing.T) {
|
||||
require.Equal(t, "", File())
|
||||
}
|
||||
|
||||
func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
SetLevelOverride("tag", "manager", DebugLevel)
|
||||
SetLevelOverride("tag", "publish", DebugLevel)
|
||||
|
||||
Time(time.Unix(11, 0).UTC()).Field("tag", "manager").Debug("this is logged")
|
||||
Time(time.Unix(12, 0).UTC()).Field("tag", "no-match").Debug("this is not logged")
|
||||
Time(time.Unix(13, 0).UTC()).Field("tag", "publish").Info("this is also logged")
|
||||
|
||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","tag":"manager"}
|
||||
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","tag":"publish"}
|
||||
`
|
||||
require.Equal(t, expected, out.String())
|
||||
require.False(t, IsFile())
|
||||
require.Equal(t, "", File())
|
||||
}
|
||||
|
||||
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
|
||||
@@ -124,10 +124,12 @@ 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
|
||||
StripePriceCacheDuration time.Duration
|
||||
BillingContact string
|
||||
EnableWeb bool
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
EnableLogin bool
|
||||
@@ -197,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,
|
||||
|
||||
@@ -39,7 +39,7 @@ func (e errHTTP) Context() log.Context {
|
||||
|
||||
func (e errHTTP) Wrap(message string, args ...any) *errHTTP {
|
||||
clone := e.clone()
|
||||
clone.Message = fmt.Sprintf("%s, %s", clone.Message, fmt.Sprintf(message, args...))
|
||||
clone.Message = fmt.Sprintf("%s; %s", clone.Message, fmt.Sprintf(message, args...))
|
||||
return &clone
|
||||
}
|
||||
|
||||
@@ -115,9 +115,9 @@ var (
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -31,7 +31,8 @@ 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}
|
||||
)
|
||||
|
||||
// logr creates a new log event with HTTP request fields
|
||||
|
||||
@@ -162,7 +162,13 @@ func New(conf *Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, userManager)
|
||||
// This awkward logic is required because Go is weird about nil types and interfaces.
|
||||
// See issue #641, and https://go.dev/play/p/uur1flrv1t3 for an example
|
||||
var auther user.Auther
|
||||
if userManager != nil {
|
||||
auther = userManager
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, auther)
|
||||
}
|
||||
s := &Server{
|
||||
config: conf,
|
||||
@@ -319,6 +325,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
if !ok {
|
||||
httpErr = errHTTPInternalError
|
||||
}
|
||||
isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode)
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode)
|
||||
ev := logvr(v, r).Err(err)
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
@@ -335,6 +342,12 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
} else {
|
||||
ev.Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
}
|
||||
if isRateLimiting && s.config.StripeSecretKey != "" {
|
||||
u := v.User()
|
||||
if u == nil || u.Tier == nil {
|
||||
httpErr = httpErr.Wrap("increase your limits with a paid plan, see %s", s.config.BaseURL)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
@@ -472,6 +485,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
BillingContact: s.config.BillingContact,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
}
|
||||
b, err := json.MarshalIndent(response, "", " ")
|
||||
@@ -509,7 +523,10 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
file := filepath.Join(s.config.AttachmentCacheDir, messageID)
|
||||
stat, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
return errHTTPNotFound.Fields(log.Context{
|
||||
"message_id": messageID,
|
||||
"error_context": "filesystem",
|
||||
})
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
@@ -530,7 +547,10 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
}, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
return errHTTPNotFound.Fields(log.Context{
|
||||
"message_id": messageID,
|
||||
"error_context": "message_cache",
|
||||
})
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -546,7 +566,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
bandwidthVisitor = s.visitor(m.Sender, nil)
|
||||
}
|
||||
if !bandwidthVisitor.BandwidthAllowed(stat.Size()) {
|
||||
return errHTTPTooManyRequestsLimitAttachmentBandwidth
|
||||
return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m)
|
||||
}
|
||||
// Actually send file
|
||||
f, err := os.Open(file)
|
||||
@@ -565,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
|
||||
@@ -577,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() {
|
||||
@@ -650,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
|
||||
}
|
||||
@@ -658,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)
|
||||
@@ -866,13 +893,17 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
}
|
||||
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
|
||||
if m.Time > attachmentExpiry {
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m)
|
||||
}
|
||||
contentLengthStr := r.Header.Get("Content-Length")
|
||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) {
|
||||
return errHTTPEntityTooLargeAttachment
|
||||
return errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{
|
||||
"message_content_length": contentLength,
|
||||
"attachment_total_size_remaining": vinfo.Stats.AttachmentTotalSizeRemaining,
|
||||
"attachment_file_size_limit": vinfo.Limits.AttachmentFileSizeLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
if m.Attachment == nil {
|
||||
@@ -984,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())
|
||||
@@ -1010,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
|
||||
}
|
||||
@@ -1099,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
|
||||
}
|
||||
@@ -1121,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)
|
||||
@@ -1164,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 {
|
||||
|
||||
@@ -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,15 +235,33 @@
|
||||
# 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
|
||||
# enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys.
|
||||
# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe.
|
||||
# Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks.
|
||||
# - billing-contact is an email address or website displayed in the "Upgrade tier" dialog to let people reach
|
||||
# out with billing questions. If unset, nothing will be displayed.
|
||||
#
|
||||
# stripe-secret-key:
|
||||
# stripe-webhook-key:
|
||||
# billing-contact:
|
||||
|
||||
# Logging options
|
||||
#
|
||||
|
||||
@@ -657,6 +657,17 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
m2 := toMessage(t, rr.Body.String())
|
||||
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
|
||||
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
|
||||
"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
|
||||
// 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.Equal(t, 0, len(ms))
|
||||
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
@@ -686,93 +701,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create user with tier
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 20,
|
||||
ReservationLimit: 2,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Subscribe anonymously
|
||||
anonCh, userCh := make(chan bool), make(chan bool)
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/mytopic/json", ``, nil) // This blocks until it's killed!
|
||||
require.Equal(t, 200, rr.Code)
|
||||
messages := toMessages(t, rr.Body.String())
|
||||
require.Equal(t, 2, len(messages)) // This is the meat. We should NOT receive the second message!
|
||||
require.Equal(t, "open", messages[0].Event)
|
||||
require.Equal(t, "message before reservation", messages[1].Message)
|
||||
anonCh <- true
|
||||
log.Info("Anonymous subscription ended")
|
||||
}()
|
||||
|
||||
// Subscribe with user
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/mytopic/json", ``, map[string]string{ // Blocks!
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
messages := toMessages(t, rr.Body.String())
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "open", messages[0].Event)
|
||||
require.Equal(t, "message before reservation", messages[1].Message)
|
||||
require.Equal(t, "message after reservation", messages[2].Message)
|
||||
userCh <- true
|
||||
log.Info("User subscription ended")
|
||||
}()
|
||||
|
||||
// Publish message (before reservation)
|
||||
time.Sleep(2 * time.Second) // Wait for subscribers
|
||||
rr = request(t, s, "POST", "/mytopic", "message before reservation", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
time.Sleep(2 * time.Second) // Wait for subscribers to receive message
|
||||
|
||||
// Reserve a topic
|
||||
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Everyone but phil should be killed
|
||||
select {
|
||||
case <-anonCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Waiting for anonymous subscription to be killed failed")
|
||||
}
|
||||
|
||||
// Publish a message
|
||||
rr = request(t, s, "POST", "/mytopic", "message after reservation", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Kill user Go routine
|
||||
s.topics["mytopic"].CancelSubscribers("<invalid>")
|
||||
|
||||
select {
|
||||
case <-userCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Waiting for user subscription to be killed failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond
|
||||
conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
@@ -794,13 +727,12 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Wait for stats queue writer
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Verify that message stats were persisted
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1), u.Stats.Messages)
|
||||
// Wait for stats queue writer, verify that message stats were persisted
|
||||
waitFor(t, func() bool {
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return int64(1) == u.Stats.Messages
|
||||
})
|
||||
|
||||
// Change tier, make a request (to reset limiters)
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
@@ -818,10 +750,11 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Verify that message stats were persisted
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
u, err = s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
|
||||
waitFor(t, func() bool {
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run!
|
||||
})
|
||||
|
||||
// Stats keep counting
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
@@ -830,5 +763,4 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, int64(2), account.Stats.Messages) // Is not reset!
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ type contextKey int
|
||||
const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
contextMatrixPushKey
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -83,6 +84,32 @@ func TestServer_PublishWithFirebase(t *testing.T) {
|
||||
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
|
||||
}
|
||||
|
||||
func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) {
|
||||
// This tests issue #641, which used to panic before the fix
|
||||
|
||||
firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json")
|
||||
contents := `{
|
||||
"type": "service_account",
|
||||
"project_id": "ntfy-test",
|
||||
"private_key_id": "fsfhskjdfhskdhfskdjfhsdf",
|
||||
"private_key": "lalala",
|
||||
"client_email": "firebase-adminsdk-muv04@ntfy-test.iam.gserviceaccount.com",
|
||||
"client_id": "123123213",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-muv04%40ntfy-test.iam.gserviceaccount.com"
|
||||
}
|
||||
`
|
||||
require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600))
|
||||
c := newTestConfig(t)
|
||||
c.FirebaseKeyFile = firebaseKeyFile
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
||||
require.Equal(t, "my first message", toMessage(t, response.Body.String()).Message)
|
||||
}
|
||||
|
||||
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
@@ -888,7 +915,15 @@ func TestServer_StatsResetter(t *testing.T) {
|
||||
require.Equal(t, int64(2), account.Stats.Messages)
|
||||
|
||||
// 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!
|
||||
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
@@ -1137,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)
|
||||
@@ -1257,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"}]}}`
|
||||
@@ -1635,9 +1748,10 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Prune and makes sure it's gone
|
||||
time.Sleep(time.Second) // Sigh ...
|
||||
s.execManager()
|
||||
require.NoFileExists(t, file)
|
||||
waitFor(t, func() bool {
|
||||
s.execManager() // May run many times
|
||||
return !util.FileExists(file)
|
||||
})
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
}
|
||||
@@ -1994,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
|
||||
@@ -2005,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) {
|
||||
@@ -2013,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.
|
||||
@@ -2044,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
|
||||
@@ -2070,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
|
||||
@@ -2097,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
|
||||
@@ -2132,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
|
||||
@@ -2179,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
|
||||
@@ -2285,3 +2444,18 @@ func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||
}
|
||||
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 {
|
||||
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{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
41
server/topic_test.go
Normal file
41
server/topic_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTopic_CancelSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
return nil
|
||||
}
|
||||
canceled1 := atomic.Bool{}
|
||||
cancelFn1 := func() {
|
||||
canceled1.Store(true)
|
||||
}
|
||||
canceled2 := atomic.Bool{}
|
||||
cancelFn2 := func() {
|
||||
canceled2.Store(true)
|
||||
}
|
||||
to := newTopic("mytopic")
|
||||
to.Subscribe(subFn, "", cancelFn1)
|
||||
to.Subscribe(subFn, "u_phil", cancelFn2)
|
||||
|
||||
to.CancelSubscribers("u_phil")
|
||||
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)
|
||||
}
|
||||
@@ -341,6 +341,7 @@ type apiConfigResponse struct {
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
util/time.go
29
util/time.go
@@ -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 {
|
||||
@@ -45,15 +54,9 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
d, err := parseDuration(s)
|
||||
if err == nil {
|
||||
return now.Add(d), nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
|
||||
// translates to 24 hours, e.g. "2d" or "20h".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err == nil {
|
||||
return d, nil
|
||||
@@ -80,6 +83,14 @@ func parseDuration(s string) (time.Duration, error) {
|
||||
return 0, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
d, err := ParseDuration(s)
|
||||
if err == nil {
|
||||
return now.Add(d), nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
||||
t, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
|
||||
@@ -78,3 +78,17 @@ func TestParseFutureTime_UnixTime(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d)
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
d, err := ParseDuration("2d")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 48*time.Hour, d)
|
||||
|
||||
d, err = ParseDuration("2h")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2*time.Hour, d)
|
||||
|
||||
d, err = ParseDuration("0")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Duration(0), d)
|
||||
}
|
||||
|
||||
273
web/package-lock.json
generated
273
web/package-lock.json
generated
@@ -2271,9 +2271,9 @@
|
||||
"integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg=="
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz",
|
||||
"integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.0.tgz",
|
||||
"integrity": "sha512-fluIaaV+GyV24CCu/ggiHdV+j4RNh85yQnAYS/G2mZODZgGmmlrgCydjUcV3YvxCm9x8nMAfThsqTni4KiXT4A==",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debug": "^4.3.2",
|
||||
@@ -2333,6 +2333,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.35.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.35.0.tgz",
|
||||
"integrity": "sha512-JXdzbRiWclLVoD8sNUjR443VVlYqiYmDVT6rGUEIEHU5YJW0gaVZwV2xgM7D4arkvASqD0IlLUVjHiFuxaftRw==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
|
||||
@@ -3104,14 +3112,14 @@
|
||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
|
||||
},
|
||||
"node_modules/@mui/base": {
|
||||
"version": "5.0.0-alpha.118",
|
||||
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.118.tgz",
|
||||
"integrity": "sha512-GAEpqhnuHjRaAZLdxFNuOf2GDTp9sUawM46oHZV4VnYPFjXJDkIYFWfIQLONb0nga92OiqS5DD/scGzVKCL0Mw==",
|
||||
"version": "5.0.0-alpha.119",
|
||||
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.119.tgz",
|
||||
"integrity": "sha512-XA5zhlYfXi67u613eIF0xRmktkatx6ERy3h+PwrMN5IcWFbgiL1guz8VpdXON+GWb8+G7B8t5oqTFIaCqaSAeA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@emotion/is-prop-valid": "^1.2.0",
|
||||
"@mui/types": "^7.2.3",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@mui/utils": "^5.11.11",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"clsx": "^1.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
@@ -3136,20 +3144,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "5.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.11.9.tgz",
|
||||
"integrity": "sha512-YGEtucQ/Nl91VZkzYaLad47Cdui51n/hW+OQm4210g4N3/nZzBxmGeKfubEalf+ShKH4aYDS86XTO6q/TpZnjQ==",
|
||||
"version": "5.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.11.11.tgz",
|
||||
"integrity": "sha512-0YK0K9GfW1ysw9z4ztWAjLW+bktf+nExMyn2+EQe1Ijb0kF2kz1kIOmb4+di0/PsXG70uCuw4DhEIdNd+JQkRA==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/icons-material": {
|
||||
"version": "5.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.11.9.tgz",
|
||||
"integrity": "sha512-SPANMk6K757Q1x48nCwPGdSNb8B71d+2hPMJ0V12VWerpSsbjZtvAPi5FAn13l2O5mwWkvI0Kne+0tCgnNxMNw==",
|
||||
"version": "5.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.11.11.tgz",
|
||||
"integrity": "sha512-Eell3ADmQVE8HOpt/LZ3zIma8JSvPh3XgnhwZLT0k5HRqZcd6F/QDHc7xsWtgz09t+UEFvOYJXjtrwKmLdwwpw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13"
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -3170,16 +3178,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/material": {
|
||||
"version": "5.11.10",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.11.10.tgz",
|
||||
"integrity": "sha512-hs1WErbiedqlJIZsljgoil908x4NMp8Lfk8di+5c7o809roqKcFTg2+k3z5ucKvs29AXcsdXrDB/kn2K6dGYIw==",
|
||||
"version": "5.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.11.11.tgz",
|
||||
"integrity": "sha512-sSe0dmKjB1IGOYt32Pcha+cXV3IIrX5L5mFAF9LDRssp/x53bluhgLLbkc8eTiJvueVvo6HAyze6EkFEYLQRXQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@mui/base": "5.0.0-alpha.118",
|
||||
"@mui/core-downloads-tracker": "^5.11.9",
|
||||
"@mui/system": "^5.11.9",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@mui/base": "5.0.0-alpha.119",
|
||||
"@mui/core-downloads-tracker": "^5.11.11",
|
||||
"@mui/system": "^5.11.11",
|
||||
"@mui/types": "^7.2.3",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@mui/utils": "^5.11.11",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"clsx": "^1.2.1",
|
||||
"csstype": "^3.1.1",
|
||||
@@ -3214,12 +3222,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/private-theming": {
|
||||
"version": "5.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.11.9.tgz",
|
||||
"integrity": "sha512-XMyVIFGomVCmCm92EvYlgq3zrC9K+J6r7IKl/rBJT2/xVYoRY6uM7jeB+Wxh7kXxnW9Dbqsr2yL3cx6wSD1sAg==",
|
||||
"version": "5.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.11.11.tgz",
|
||||
"integrity": "sha512-yLgTkjNC1mpye2SOUkc+zQQczUpg8NvQAETvxwXTMzNgJK1pv4htL7IvBM5vmCKG7IHAB3hX26W2u6i7bxwF3A==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@mui/utils": "^5.11.11",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3240,11 +3248,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/styled-engine": {
|
||||
"version": "5.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.11.9.tgz",
|
||||
"integrity": "sha512-bkh2CjHKOMy98HyOc8wQXEZvhOmDa/bhxMUekFX5IG0/w4f5HJ8R6+K6nakUUYNEgjOWPYzNPrvGB8EcGbhahQ==",
|
||||
"version": "5.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.11.11.tgz",
|
||||
"integrity": "sha512-wV0UgW4lN5FkDBXefN8eTYeuE9sjyQdg5h94vtwZCUamGQEzmCOtir4AakgmbWMy0x8OLjdEUESn9wnf5J9MOg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@emotion/cache": "^11.10.5",
|
||||
"csstype": "^3.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
@@ -3271,15 +3279,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/system": {
|
||||
"version": "5.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.11.9.tgz",
|
||||
"integrity": "sha512-h6uarf+l3FO6l75Nf7yO+qDGrIoa1DM9nAMCUFZQsNCDKOInRzcptnm8M1w/Z3gVetfeeGoIGAYuYKbft6KZZA==",
|
||||
"version": "5.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.11.11.tgz",
|
||||
"integrity": "sha512-a9gaOAJBjpzypDfhbGZQ8HzdcxdxsKkFvbp1aAWZhFHBPdehEkARNh7mj851VfEhD/GdffYt85PFKFKdUta5Eg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@mui/private-theming": "^5.11.9",
|
||||
"@mui/styled-engine": "^5.11.9",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@mui/private-theming": "^5.11.11",
|
||||
"@mui/styled-engine": "^5.11.11",
|
||||
"@mui/types": "^7.2.3",
|
||||
"@mui/utils": "^5.11.9",
|
||||
"@mui/utils": "^5.11.11",
|
||||
"clsx": "^1.2.1",
|
||||
"csstype": "^3.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
@@ -3323,11 +3331,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/utils": {
|
||||
"version": "5.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.11.9.tgz",
|
||||
"integrity": "sha512-eOJaqzcEs4qEwolcvFAmXGpln+uvouvOS9FUX6Wkrte+4I8rZbjODOBDVNlK+V6/ziTfD4iNKC0G+KfOTApbqg==",
|
||||
"version": "5.11.11",
|
||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.11.11.tgz",
|
||||
"integrity": "sha512-neMM5rrEXYQrOrlxUfns/TGgX4viS8K2zb9pbQh11/oUUYFlGI32Tn+PHePQx7n6Fy/0zq6WxdBFC9VpnJ5JrQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@types/prop-types": "^15.7.5",
|
||||
"@types/react-is": "^16.7.1 || ^17.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
@@ -3471,9 +3479,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.2.tgz",
|
||||
"integrity": "sha512-t54ONhl/h75X94SWsHGQ4G/ZrCEguKSRQr7DrjTciJXW0YU1QhlwYeycvK5JgkzlxmvrK7wq1NB/PLtHxoiDcA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.3.tgz",
|
||||
"integrity": "sha512-YRHie1yQEj0kqqCTCJEfHqYSSNlZQ696QJG+MMiW4mxSl9I0ojz/eRhJS4fs88Z5i6D1SmoF9d3K99/QOhI8/w==",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
@@ -3985,9 +3993,9 @@
|
||||
"integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz",
|
||||
"integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ=="
|
||||
"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",
|
||||
@@ -4125,13 +4133,13 @@
|
||||
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA=="
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.53.0.tgz",
|
||||
"integrity": "sha512-alFpFWNucPLdUOySmXCJpzr6HKC3bu7XooShWM+3w/EL6J2HIoB2PFxpLnq4JauWVk6DiVeNKzQlFEaE+X9sGw==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.54.0.tgz",
|
||||
"integrity": "sha512-+hSN9BdSr629RF02d7mMtXhAJvDTyCbprNYJKrXETlul/Aml6YZwd90XioVbjejQeHbb3R8Dg0CkRgoJDxo8aw==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "5.53.0",
|
||||
"@typescript-eslint/type-utils": "5.53.0",
|
||||
"@typescript-eslint/utils": "5.53.0",
|
||||
"@typescript-eslint/scope-manager": "5.54.0",
|
||||
"@typescript-eslint/type-utils": "5.54.0",
|
||||
"@typescript-eslint/utils": "5.54.0",
|
||||
"debug": "^4.3.4",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"ignore": "^5.2.0",
|
||||
@@ -4158,11 +4166,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/experimental-utils": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.53.0.tgz",
|
||||
"integrity": "sha512-4SklZEwRn0jqkhtW+pPZpbKFXprwGneBndRM0TGzJu/LWdb9QV2hBgFIVU9AREo02BzqFvyG/ypd+xAW5YGhXw==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.54.0.tgz",
|
||||
"integrity": "sha512-rRYECOTh5V3iWsrOzXi7h1jp3Bi9OkJHrb3wECi3DVqMGTilo9wAYmCbT+6cGdrzUY3MWcAa2mESM6FMik6tVw==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/utils": "5.53.0"
|
||||
"@typescript-eslint/utils": "5.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
@@ -4176,13 +4184,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.53.0.tgz",
|
||||
"integrity": "sha512-MKBw9i0DLYlmdOb3Oq/526+al20AJZpANdT6Ct9ffxcV8nKCHz63t/S0IhlTFNsBIHJv+GY5SFJ0XfqVeydQrQ==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.54.0.tgz",
|
||||
"integrity": "sha512-aAVL3Mu2qTi+h/r04WI/5PfNWvO6pdhpeMRWk9R7rEV4mwJNzoWf5CCU5vDKBsPIFQFjEq1xg7XBI2rjiMXQbQ==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "5.53.0",
|
||||
"@typescript-eslint/types": "5.53.0",
|
||||
"@typescript-eslint/typescript-estree": "5.53.0",
|
||||
"@typescript-eslint/scope-manager": "5.54.0",
|
||||
"@typescript-eslint/types": "5.54.0",
|
||||
"@typescript-eslint/typescript-estree": "5.54.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4202,12 +4210,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.53.0.tgz",
|
||||
"integrity": "sha512-Opy3dqNsp/9kBBeCPhkCNR7fmdSQqA+47r21hr9a14Bx0xnkElEQmhoHga+VoaoQ6uDHjDKmQPIYcUcKJifS7w==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.54.0.tgz",
|
||||
"integrity": "sha512-VTPYNZ7vaWtYna9M4oD42zENOBrb+ZYyCNdFs949GcN8Miwn37b8b7eMj+EZaq7VK9fx0Jd+JhmkhjFhvnovhg==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.53.0",
|
||||
"@typescript-eslint/visitor-keys": "5.53.0"
|
||||
"@typescript-eslint/types": "5.54.0",
|
||||
"@typescript-eslint/visitor-keys": "5.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
@@ -4218,12 +4226,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.53.0.tgz",
|
||||
"integrity": "sha512-HO2hh0fmtqNLzTAme/KnND5uFNwbsdYhCZghK2SoxGp3Ifn2emv+hi0PBUjzzSh0dstUIFqOj3bp0AwQlK4OWw==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.54.0.tgz",
|
||||
"integrity": "sha512-WI+WMJ8+oS+LyflqsD4nlXMsVdzTMYTxl16myXPaCXnSgc7LWwMsjxQFZCK/rVmTZ3FN71Ct78ehO9bRC7erYQ==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/typescript-estree": "5.53.0",
|
||||
"@typescript-eslint/utils": "5.53.0",
|
||||
"@typescript-eslint/typescript-estree": "5.54.0",
|
||||
"@typescript-eslint/utils": "5.54.0",
|
||||
"debug": "^4.3.4",
|
||||
"tsutils": "^3.21.0"
|
||||
},
|
||||
@@ -4244,9 +4252,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.53.0.tgz",
|
||||
"integrity": "sha512-5kcDL9ZUIP756K6+QOAfPkigJmCPHcLN7Zjdz76lQWWDdzfOhZDTj1irs6gPBKiXx5/6O3L0+AvupAut3z7D2A==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.54.0.tgz",
|
||||
"integrity": "sha512-nExy+fDCBEgqblasfeE3aQ3NuafBUxZxgxXcYfzYRZFHdVvk5q60KhCSkG0noHgHRo/xQ/BOzURLZAafFpTkmQ==",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
@@ -4256,12 +4264,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.53.0.tgz",
|
||||
"integrity": "sha512-eKmipH7QyScpHSkhbptBBYh9v8FxtngLquq292YTEQ1pxVs39yFBlLC1xeIZcPPz1RWGqb7YgERJRGkjw8ZV7w==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.54.0.tgz",
|
||||
"integrity": "sha512-X2rJG97Wj/VRo5YxJ8Qx26Zqf0RRKsVHd4sav8NElhbZzhpBI8jU54i6hfo9eheumj4oO4dcRN1B/zIVEqR/MQ==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.53.0",
|
||||
"@typescript-eslint/visitor-keys": "5.53.0",
|
||||
"@typescript-eslint/types": "5.54.0",
|
||||
"@typescript-eslint/visitor-keys": "5.54.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -4282,15 +4290,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.53.0.tgz",
|
||||
"integrity": "sha512-VUOOtPv27UNWLxFwQK/8+7kvxVC+hPHNsJjzlJyotlaHjLSIgOCKj9I0DBUjwOOA64qjBwx5afAPjksqOxMO0g==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.54.0.tgz",
|
||||
"integrity": "sha512-cuwm8D/Z/7AuyAeJ+T0r4WZmlnlxQ8wt7C7fLpFlKMR+dY6QO79Cq1WpJhvZbMA4ZeZGHiRWnht7ZJ8qkdAunw==",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@typescript-eslint/scope-manager": "5.53.0",
|
||||
"@typescript-eslint/types": "5.53.0",
|
||||
"@typescript-eslint/typescript-estree": "5.53.0",
|
||||
"@typescript-eslint/scope-manager": "5.54.0",
|
||||
"@typescript-eslint/types": "5.54.0",
|
||||
"@typescript-eslint/typescript-estree": "5.54.0",
|
||||
"eslint-scope": "^5.1.1",
|
||||
"eslint-utils": "^3.0.0",
|
||||
"semver": "^7.3.7"
|
||||
@@ -4327,11 +4335,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "5.53.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.53.0.tgz",
|
||||
"integrity": "sha512-JqNLnX3leaHFZEN0gCh81sIvgrp/2GOACZNgO4+Tkf64u51kTpAyWFOY8XHx8XuXr3N2C9zgPPHtcpMg6z1g0w==",
|
||||
"version": "5.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.54.0.tgz",
|
||||
"integrity": "sha512-xu4wT7aRCakGINTLGeyGqDn+78BwFlggwBjnHa1ar/KaGagnmwLYmlrXIrgAaQ3AE1Vd6nLfKASm7LrFHNbKGA==",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "5.53.0",
|
||||
"@typescript-eslint/types": "5.54.0",
|
||||
"eslint-visitor-keys": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5465,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",
|
||||
@@ -5811,9 +5819,9 @@
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.28.0.tgz",
|
||||
"integrity": "sha512-GiZn9D4Z/rSYvTeg1ljAIsEqFm0LaN9gVtwDCrKL80zHtS31p9BAjmTxVqTQDMpwlMolJZOFntUG2uwyj7DAqw==",
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.29.0.tgz",
|
||||
"integrity": "sha512-VG23vuEisJNkGl6XQmFJd3rEG/so/CNatqeE+7uZAwTSwFeB/qaO0be8xZYUNWprJ/GIwL8aMt9cj1kvbpTZhg==",
|
||||
"hasInstallScript": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -5821,9 +5829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.28.0.tgz",
|
||||
"integrity": "sha512-myzPgE7QodMg4nnd3K1TDoES/nADRStM8Gpz0D6nhkwbmwEnE0ZGJgoWsvQ722FR8D7xS0n0LV556RcEicjTyg==",
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.29.0.tgz",
|
||||
"integrity": "sha512-ScMn3uZNAFhK2DGoEfErguoiAHhV2Ju+oJo/jK08p7B3f3UhocUrCCkTvnZaiS+edl5nlIoiBXKcwMc6elv4KQ==",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.21.5"
|
||||
},
|
||||
@@ -5833,9 +5841,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-pure": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.28.0.tgz",
|
||||
"integrity": "sha512-DSOVleA9/v3LNj/vFxAPfUHttKTzrB2RXhAPvR5TPXn4vrra3Z2ssytvRyt8eruJwAfwAiFADEbrjcRdcvPLQQ==",
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.29.0.tgz",
|
||||
"integrity": "sha512-v94gUjN5UTe1n0yN/opTihJ8QBWD2O8i19RfTZR7foONPWArnjB96QA/wk5ozu1mm6ja3udQCzOzwQXTxi3xOQ==",
|
||||
"hasInstallScript": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -6716,9 +6724,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.4.311",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.311.tgz",
|
||||
"integrity": "sha512-RoDlZufvrtr2Nx3Yx5MB8jX3aHIxm8nRWPJm3yVvyHmyKaRvn90RjzB6hNnt0AkhS3IInJdyRfQb4mWhPvUjVw=="
|
||||
"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",
|
||||
@@ -7002,11 +7010,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.34.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.34.0.tgz",
|
||||
"integrity": "sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg==",
|
||||
"version": "8.35.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.35.0.tgz",
|
||||
"integrity": "sha512-BxAf1fVL7w+JLRQhWl2pzGeSiGqbWumV4WNvc9Rhp6tiCtm4oHnyPBSEtMGZwrQgudFQ+otqzWoPB7x+hxoWsw==",
|
||||
"dependencies": {
|
||||
"@eslint/eslintrc": "^1.4.1",
|
||||
"@eslint/eslintrc": "^2.0.0",
|
||||
"@eslint/js": "8.35.0",
|
||||
"@humanwhocodes/config-array": "^0.11.8",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
@@ -7020,7 +7029,7 @@
|
||||
"eslint-utils": "^3.0.0",
|
||||
"eslint-visitor-keys": "^3.3.0",
|
||||
"espree": "^9.4.0",
|
||||
"esquery": "^1.4.0",
|
||||
"esquery": "^1.4.2",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^6.0.1",
|
||||
@@ -7632,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"
|
||||
},
|
||||
@@ -9063,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": {
|
||||
@@ -11724,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"
|
||||
}
|
||||
@@ -14355,11 +14364,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz",
|
||||
"integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==",
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.2.tgz",
|
||||
"integrity": "sha512-lF7S0UmXI5Pd8bmHvMdPKI4u4S5McxmHnzJhrYi9ZQ6wE+DA8JN5BzVC5EEBuduWWDaiJ8u6YhVOCmThBli+rw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.3.2"
|
||||
"@remix-run/router": "1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -14369,12 +14378,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.8.1.tgz",
|
||||
"integrity": "sha512-67EXNfkQgf34P7+PSb6VlBuaacGhkKn3kpE51+P6zYSG2kiRoumXEL6e27zTa9+PGF2MNXbgIUHTVlleLbIcHQ==",
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.8.2.tgz",
|
||||
"integrity": "sha512-N/oAF1Shd7g4tWy+75IIufCGsHBqT74tnzHQhbiUTYILYF0Blk65cg+HPZqwC+6SqEyx033nKqU7by38v3lBZg==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.3.2",
|
||||
"react-router": "6.8.1"
|
||||
"@remix-run/router": "1.3.3",
|
||||
"react-router": "6.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
// During web development, you may change values here for rapid testing.
|
||||
|
||||
var config = {
|
||||
base_url: "https://127.0.0.1", // to test against a different server
|
||||
base_url: window.location.origin, // Change to test against a different server
|
||||
app_root: "/app",
|
||||
enable_login: true,
|
||||
enable_signup: true,
|
||||
enable_payments: true,
|
||||
enable_reservations: true,
|
||||
billing_contact: "",
|
||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
||||
};
|
||||
|
||||
@@ -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 الخاص بالموضوع."
|
||||
}
|
||||
|
||||
@@ -217,5 +217,16 @@
|
||||
"action_bar_reservation_edit": "Промяна на резервацията",
|
||||
"action_bar_sign_up": "Регистриране",
|
||||
"account_basics_title": "Профил",
|
||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>."
|
||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
||||
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
|
||||
"display_name_dialog_placeholder": "Наименование",
|
||||
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
||||
"account_basics_username_title": "Потребител",
|
||||
"account_basics_username_description": "Хей, това сте вие ❤",
|
||||
"account_basics_username_admin_tooltip": "Вие сте администратор",
|
||||
"account_basics_password_title": "Парола",
|
||||
"account_delete_dialog_label": "Парола"
|
||||
}
|
||||
|
||||
225
web/public/static/langs/da.json
Normal file
225
web/public/static/langs/da.json
Normal file
@@ -0,0 +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"
|
||||
}
|
||||
@@ -236,6 +236,8 @@
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.",
|
||||
"account_upgrade_dialog_tier_selected_label": "Selected",
|
||||
"account_upgrade_dialog_tier_current_label": "Current",
|
||||
"account_upgrade_dialog_billing_contact_email": "For billing questions, please <Link>contact us</Link> directly.",
|
||||
"account_upgrade_dialog_billing_contact_website": "For billing questions, please refer to our <Link>website</Link>.",
|
||||
"account_upgrade_dialog_button_cancel": "Cancel",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Sign up now",
|
||||
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,9 +3,8 @@ import {useContext, useEffect, useState} from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {Alert, Badge, CardActionArea, CardContent, Chip, ListItem, Stack, Switch, useMediaQuery} from "@mui/material";
|
||||
import {Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery} from "@mui/material";
|
||||
import theme from "./theme";
|
||||
import DialogFooter from "./DialogFooter";
|
||||
import Button from "@mui/material/Button";
|
||||
import accountApi, {SubscriptionInterval} from "../app/AccountApi";
|
||||
import session from "../app/Session";
|
||||
@@ -22,6 +21,8 @@ import ListItemText from "@mui/material/ListItemText";
|
||||
import Box from "@mui/material/Box";
|
||||
import {NavLink} from "react-router-dom";
|
||||
import {UnauthorizedError} from "../app/errors";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
|
||||
const UpgradeDialog = (props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -56,7 +57,7 @@ const UpgradeDialog = (props) => {
|
||||
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
|
||||
submitAction = Action.REDIRECT_SIGNUP;
|
||||
banner = null;
|
||||
} else if (currentTierCode === newTierCode && currentInterval === interval) {
|
||||
} else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) {
|
||||
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
|
||||
submitAction = null;
|
||||
banner = (currentTierCode) ? Banner.PRORATION_INFO : null;
|
||||
@@ -204,10 +205,35 @@ const UpgradeDialog = (props) => {
|
||||
</Alert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogFooter status={error}>
|
||||
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
|
||||
</DialogFooter>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: '24px',
|
||||
paddingBottom: '8px',
|
||||
}}>
|
||||
<DialogContentText
|
||||
component="div"
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
margin: '0px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
>
|
||||
{config.billing_contact.indexOf('@') !== -1 &&
|
||||
<><Trans i18nKey="account_upgrade_dialog_billing_contact_email" components={{ Link: <Link href={`mailto:${config.billing_contact}`}/> }}/>{" "}</>
|
||||
}
|
||||
{config.billing_contact.match(`^http?s://`) &&
|
||||
<><Trans i18nKey="account_upgrade_dialog_billing_contact_website" components={{ Link: <Link href={config.billing_contact} target="_blank"/> }}/>{" "}</>
|
||||
}
|
||||
{error}
|
||||
</DialogContentText>
|
||||
<DialogActions sx={{paddingRight: 2}}>
|
||||
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
|
||||
</DialogActions>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user