Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5247c50f4 | ||
|
|
1edbda4f31 | ||
|
|
de7b7218e4 | ||
|
|
19a4e95a3a | ||
|
|
4578835a8f | ||
|
|
aead619dea | ||
|
|
deeefee8c0 | ||
|
|
5e380e147f | ||
|
|
ba5c3a164d | ||
|
|
47da3aeea6 | ||
|
|
9ed96e5d8b | ||
|
|
04aff72631 | ||
|
|
6fbcd85d17 | ||
|
|
8f60294c5b | ||
|
|
677b44ce61 | ||
|
|
000248e6aa | ||
|
|
359c789c34 | ||
|
|
34e9a771ce | ||
|
|
60b8588129 | ||
|
|
7eeaeb8398 | ||
|
|
c99d8b66c2 | ||
|
|
960f690dd6 | ||
|
|
54514454bf | ||
|
|
d8c8f31846 | ||
|
|
ae27c3a5ab | ||
|
|
48cb816111 | ||
|
|
ff904a5ca6 | ||
|
|
8e7de80353 | ||
|
|
9c8a8f8795 | ||
|
|
df73c6f655 | ||
|
|
c1e657db8b | ||
|
|
62c8a13ed4 | ||
|
|
994266ab04 | ||
|
|
a41e3a1e76 | ||
|
|
86bec660bf | ||
|
|
30301c8a7f | ||
|
|
7b470a7f6f | ||
|
|
9d5891963a | ||
|
|
de8e3bc2aa | ||
|
|
d3f7aa7008 | ||
|
|
bbfaf2fc4d | ||
|
|
db4ac158e3 | ||
|
|
7a33e16945 | ||
|
|
eac49feb04 | ||
|
|
849884c947 | ||
|
|
2cb4d089ab | ||
|
|
dc797f8594 | ||
|
|
a49cafbadb | ||
|
|
0aee6252bb | ||
|
|
0e6a483b2f | ||
|
|
20c014ba8d | ||
|
|
926967b6e7 |
40
README.md
@@ -56,20 +56,18 @@ For announcements of new releases and cutting-edge beta versions, please subscri
|
|||||||
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
|
topic. If you'd like to test the iOS app, join [TestFlight](https://testflight.apple.com/join/P1fFnAm9). For Android betas,
|
||||||
join Discord/Matrix (I'll eventually make a testing channel in Google Play).
|
join Discord/Matrix (I'll eventually make a testing channel in Google Play).
|
||||||
|
|
||||||
## Contributing
|
|
||||||
I welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out
|
|
||||||
on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)
|
|
||||||
for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
|
||||||
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/ntfy/">
|
|
||||||
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
If you'd like to support the ntfy maintainers, please consider donating to [GitHub Sponsors](https://github.com/sponsors/binwiederhier) or
|
||||||
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
and [Liberapay](https://liberapay.com/ntfy). We would be humbled if you helped carry the server and developer
|
||||||
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
account costs. Even small donations are very much appreciated.
|
||||||
|
|
||||||
|
Thank you to our commercial sponsors, who help keep the service running and the development going:
|
||||||
|
|
||||||
|
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||||
|
|
||||||
|
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
|
||||||
|
|
||||||
|
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||||
|
|
||||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||||
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
||||||
@@ -210,13 +208,21 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
|||||||
<a href="https://github.com/user8446"><img src="https://github.com/user8446.png" width="40px" /></a>
|
<a href="https://github.com/user8446"><img src="https://github.com/user8446.png" width="40px" /></a>
|
||||||
<a href="https://github.com/cdf-eagles"><img src="https://github.com/cdf-eagles.png" width="40px" /></a>
|
<a href="https://github.com/cdf-eagles"><img src="https://github.com/cdf-eagles.png" width="40px" /></a>
|
||||||
|
|
||||||
I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/),
|
## Contributing
|
||||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
I welcome any contributions. Just create a PR or an issue. For larger features/ideas, please reach out
|
||||||
|
on Discord/Matrix first to see if I'd accept them. To contribute code, check out the [build instructions](https://ntfy.sh/docs/develop/)
|
||||||
|
for the server and the Android app. Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
|
||||||
|
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
|
||||||
|
|
||||||
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
<a href="https://hosted.weblate.org/engage/ntfy/">
|
||||||
|
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for
|
||||||
|
everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity
|
||||||
|
and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste,
|
||||||
|
color, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
**We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.**
|
**We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.**
|
||||||
|
|
||||||
|
|||||||
BIN
assets/sponsors/magicbell.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -69,6 +69,7 @@ Examples:
|
|||||||
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
|
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
|
||||||
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
||||||
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
||||||
|
echo 'message' | ntfy publish mytopic # Send message from stdin
|
||||||
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
||||||
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
|
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
|
||||||
ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes
|
ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes
|
||||||
@@ -254,6 +255,15 @@ func parseTopicMessageCommand(c *cli.Context) (topic string, message string, com
|
|||||||
if c.String("message") != "" {
|
if c.String("message") != "" {
|
||||||
message = c.String("message")
|
message = c.String("message")
|
||||||
}
|
}
|
||||||
|
if message == "" && isStdinRedirected() {
|
||||||
|
var data []byte
|
||||||
|
data, err = io.ReadAll(io.LimitReader(c.App.Reader, 1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to read from stdin: %s", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,3 +322,12 @@ func runAndWaitForCommand(command []string) (message string, err error) {
|
|||||||
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
|
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
|
||||||
return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
|
return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isStdinRedirected() bool {
|
||||||
|
stat, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to stat stdin: %s", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (stat.Mode() & os.ModeCharDevice) == 0
|
||||||
|
}
|
||||||
|
|||||||
61
cmd/serve.go
@@ -5,13 +5,6 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
|
||||||
"heckel.io/ntfy/v2/log"
|
|
||||||
"heckel.io/ntfy/v2/server"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
@@ -22,6 +15,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/server"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -79,6 +80,7 @@ var flagsServe = append(
|
|||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||||
|
altsrc.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.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||||
@@ -87,8 +89,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-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
|
||||||
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.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
|
||||||
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
||||||
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)"}),
|
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)"}),
|
||||||
@@ -189,7 +194,11 @@ func execServe(c *cli.Context) error {
|
|||||||
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||||
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
|
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
|
||||||
|
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
|
||||||
|
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
|
||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
|
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
||||||
|
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
||||||
stripeSecretKey := c.String("stripe-secret-key")
|
stripeSecretKey := c.String("stripe-secret-key")
|
||||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||||
billingContact := c.String("billing-contact")
|
billingContact := c.String("billing-contact")
|
||||||
@@ -318,6 +327,12 @@ func execServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||||
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||||
|
} else if behindProxy && proxyForwardedHeader == "" {
|
||||||
|
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
|
||||||
|
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
|
||||||
|
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||||
|
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||||
|
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backwards compatibility
|
// Backwards compatibility
|
||||||
@@ -343,14 +358,24 @@ func execServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve hosts
|
// Resolve hosts
|
||||||
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
|
visitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0)
|
||||||
for _, host := range visitorRequestLimitExemptHosts {
|
for _, host := range visitorRequestLimitExemptHosts {
|
||||||
ips, err := parseIPHostPrefix(host)
|
prefixes, err := parseIPHostPrefix(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...)
|
visitorRequestLimitExemptPrefixes = append(visitorRequestLimitExemptPrefixes, prefixes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse trusted prefixes
|
||||||
|
trustedProxyPrefixes := make([]netip.Prefix, 0)
|
||||||
|
for _, host := range proxyTrustedHosts {
|
||||||
|
prefixes, err := parseIPHostPrefix(host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot resolve trusted proxy host %s: %s", host, err.Error())
|
||||||
|
}
|
||||||
|
trustedProxyPrefixes = append(trustedProxyPrefixes, prefixes...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stripe things
|
// Stripe things
|
||||||
@@ -406,16 +431,20 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.MessageDelayMax = messageDelayLimit
|
conf.MessageDelayMax = messageDelayLimit
|
||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
|
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
||||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||||
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
|
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
|
||||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||||
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
conf.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes
|
||||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
|
||||||
|
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
|
conf.ProxyForwardedHeader = proxyForwardedHeader
|
||||||
|
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
|
||||||
conf.StripeSecretKey = stripeSecretKey
|
conf.StripeSecretKey = stripeSecretKey
|
||||||
conf.StripeWebhookKey = stripeWebhookKey
|
conf.StripeWebhookKey = stripeWebhookKey
|
||||||
conf.BillingContact = billingContact
|
conf.BillingContact = billingContact
|
||||||
@@ -425,7 +454,6 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.EnableMetrics = enableMetrics
|
conf.EnableMetrics = enableMetrics
|
||||||
conf.MetricsListenHTTP = metricsListenHTTP
|
conf.MetricsListenHTTP = metricsListenHTTP
|
||||||
conf.ProfileListenHTTP = profileListenHTTP
|
conf.ProfileListenHTTP = profileListenHTTP
|
||||||
conf.Version = c.App.Version
|
|
||||||
conf.WebPushPrivateKey = webPushPrivateKey
|
conf.WebPushPrivateKey = webPushPrivateKey
|
||||||
conf.WebPushPublicKey = webPushPublicKey
|
conf.WebPushPublicKey = webPushPublicKey
|
||||||
conf.WebPushFile = webPushFile
|
conf.WebPushFile = webPushFile
|
||||||
@@ -433,6 +461,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.WebPushStartupQueries = webPushStartupQueries
|
conf.WebPushStartupQueries = webPushStartupQueries
|
||||||
conf.WebPushExpiryDuration = webPushExpiryDuration
|
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||||
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||||
|
conf.Version = c.App.Version
|
||||||
|
|
||||||
// Set up hot-reloading of config
|
// Set up hot-reloading of config
|
||||||
go sigHandlerConfigReload(config)
|
go sigHandlerConfigReload(config)
|
||||||
@@ -465,7 +494,7 @@ func sigHandlerConfigReload(config string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||||
// Try parsing as prefix, e.g. 10.0.1.0/24
|
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
|
||||||
prefix, err := netip.ParsePrefix(host)
|
prefix, err := netip.ParsePrefix(host)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
prefixes = append(prefixes, prefix.Masked())
|
prefixes = append(prefixes, prefix.Masked())
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: "2.1"
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy
|
image: binwiederhier/ntfy
|
||||||
@@ -14,4 +13,3 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|||||||
139
docs/config.md
@@ -18,8 +18,8 @@ get a list of [command line options](#command-line-options).
|
|||||||
|
|
||||||
## Example config
|
## Example config
|
||||||
!!! info
|
!!! info
|
||||||
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file.
|
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. It contains examples and detailed descriptions of all the settings.
|
||||||
It contains examples and detailed descriptions of all the settings.
|
You may also want to look at how ntfy.sh is configured in the [ntfy-ansible](https://github.com/binwiederhier/ntfy-ansible) repository.
|
||||||
|
|
||||||
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
|
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
|
||||||
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
|
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
|
||||||
@@ -79,7 +79,6 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
|||||||
|
|
||||||
=== "Docker Compose (w/ auth, cache, attachments)"
|
=== "Docker Compose (w/ auth, cache, attachments)"
|
||||||
``` yaml
|
``` yaml
|
||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy
|
image: binwiederhier/ntfy
|
||||||
@@ -101,7 +100,6 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
|||||||
|
|
||||||
=== "Docker Compose (w/ auth, cache, web push, iOS)"
|
=== "Docker Compose (w/ auth, cache, web push, iOS)"
|
||||||
``` yaml
|
``` yaml
|
||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy
|
image: binwiederhier/ntfy
|
||||||
@@ -553,17 +551,91 @@ It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache),
|
|||||||
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
|
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
|
||||||
Whatever your reasons may be, there are a few things to consider.
|
Whatever your reasons may be, there are a few things to consider.
|
||||||
|
|
||||||
|
### IP-based rate limiting
|
||||||
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
|
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
|
||||||
[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,
|
[rate limiting](#rate-limiting) logic to use the header configured in `proxy-forwarded-header` (default is `X-Forwarded-For`)
|
||||||
as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
|
as the primary identifier for a visitor, as opposed to the remote IP address.
|
||||||
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
|
|
||||||
|
|
||||||
=== "/etc/ntfy/server.yml"
|
If the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the
|
||||||
|
ntfy server, they all share the proxy's IP address.
|
||||||
|
|
||||||
|
Relevant flags to consider:
|
||||||
|
|
||||||
|
* `behind-proxy` makes it so that the real visitor IP address is extracted from the header defined in `proxy-forwarded-header`.
|
||||||
|
Without this, the remote address of the incoming connection is used (default: `false`).
|
||||||
|
* `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`),
|
||||||
|
a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style
|
||||||
|
header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`).
|
||||||
|
* `proxy-trusted-hosts` is a comma-separated list of IP addresses, hosts or CIDRs that are removed from the forwarded header
|
||||||
|
to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
|
||||||
|
the forwarded header (default: empty).
|
||||||
|
* `visitor-prefix-bits-ipv4` is the number of bits of the IPv4 address to use for rate limiting (default is `32`, which is the entire
|
||||||
|
IP address). In IPv4 environments, by default, a visitor's **full IPv4 address** is used as-is for rate limiting. This means that
|
||||||
|
if someone publishes messages from multiple IP addresses, they will be counted as separate visitors. You can adjust this by setting the `visitor-prefix-bits-ipv4` config option. To group visitors in a /24 subnet and count them as one, for instance,
|
||||||
|
set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor.
|
||||||
|
* `visitor-prefix-bits-ipv6` is the number of bits of the IPv6 address to use for rate limiting (default is `64`, which is a /64 subnet).
|
||||||
|
In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that `2001:db8:25:86:1::1` and
|
||||||
|
`2001:db8:25:86:2::1` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to adjust this behavior.
|
||||||
|
See [IPv6 considerations](#ipv6-considerations) for more details.
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (behind a proxy)"
|
||||||
``` yaml
|
``` yaml
|
||||||
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting
|
||||||
|
#
|
||||||
|
# Example: If "X-Forwarded-For: 9.9.9.9, 1.2.3.4" is set,
|
||||||
|
# the visitor IP will be 1.2.3.4 (right-most address).
|
||||||
|
#
|
||||||
behind-proxy: true
|
behind-proxy: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (X-Client-IP header)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to use "X-Client-IP" header to identify visitors for rate limiting
|
||||||
|
#
|
||||||
|
# Example: If "X-Client-IP: 9.9.9.9" is set,
|
||||||
|
# the visitor IP will be 9.9.9.9.
|
||||||
|
#
|
||||||
|
behind-proxy: true
|
||||||
|
proxy-forwarded-header: "X-Client-IP"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (Forwarded header)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to use "Forwarded" header (RFC 7239) to identify visitors for rate limiting
|
||||||
|
#
|
||||||
|
# Example: If "Forwarded: for=1.2.3.4;by=proxy.example.com, for=9.9.9.9" is set,
|
||||||
|
# the visitor IP will be 9.9.9.9.
|
||||||
|
#
|
||||||
|
behind-proxy: true
|
||||||
|
proxy-forwarded-header: "Forwarded"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (multiple proxies)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting,
|
||||||
|
# and to strip the IP addresses of the proxies 1.2.3.4 and 1.2.3.5
|
||||||
|
#
|
||||||
|
# Example: If "X-Forwarded-For: 9.9.9.9, 1.2.3.4" is set,
|
||||||
|
# the visitor IP will be 9.9.9.9 (right-most unknown address).
|
||||||
|
#
|
||||||
|
behind-proxy: true
|
||||||
|
proxy-trusted-hosts: "1.2.3.0/24, 1.2.2.2, 2001:db8::/64"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (adjusted IPv4/IPv6 prefixes proxies)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to treat visitors as being in a /24 subnet (IPv4) or /48 subnet (IPv6)
|
||||||
|
# as one visitor, so that they are counted as one for rate limiting.
|
||||||
|
#
|
||||||
|
# Example 1: If 1.2.3.4 and 1.2.3.5 publish a message, the visitor 1.2.3.0 will have
|
||||||
|
# used 2 messages.
|
||||||
|
# Example 2: If 2001:db8:2500:1::1 and 2001:db8:2500:2::1 publish a message, the visitor
|
||||||
|
# 2001:db8:2500:: will have used 2 messages.
|
||||||
|
#
|
||||||
|
visitor-prefix-bits-ipv4: 24
|
||||||
|
visitor-prefix-bits-ipv6: 48
|
||||||
|
```
|
||||||
|
|
||||||
### TLS/SSL
|
### TLS/SSL
|
||||||
ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you
|
ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you
|
||||||
are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below).
|
are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below).
|
||||||
@@ -1086,6 +1158,18 @@ If this ever happens, there will be a log message that looks something like this
|
|||||||
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
|
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### IPv6 considerations
|
||||||
|
By default, rate limiting for IPv6 is done using the `/64` subnet of the visitor's IPv6 address. This means that all visitors
|
||||||
|
in the same `/64` subnet are treated as one visitor. This is done to prevent abuse, as IPv6 subnet assignments are typically
|
||||||
|
much larger than IPv4 subnets (and much cheaper), and it is common for ISPs to assign large subnets to their customers.
|
||||||
|
|
||||||
|
Other than that, rate limiting for IPv6 is done the same way as for IPv4, using the visitor's IP address or subnet to identify them.
|
||||||
|
|
||||||
|
There are two options to configure the number of bits used for rate limiting (for IPv4 and IPv6):
|
||||||
|
|
||||||
|
- `visitor-prefix-bits-ipv4` is number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
|
||||||
|
- `visitor-prefix-bits-ipv6` is number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
|
||||||
|
|
||||||
### Subscriber-based rate limiting
|
### Subscriber-based rate limiting
|
||||||
By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment
|
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
|
size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits
|
||||||
@@ -1250,6 +1334,25 @@ Note that if you run nginx in a container, append `, chain=DOCKER-USER` to the j
|
|||||||
is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD`
|
is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD`
|
||||||
chain.
|
chain.
|
||||||
|
|
||||||
|
The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to
|
||||||
|
4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.
|
||||||
|
|
||||||
|
## IPv6 support
|
||||||
|
ntfy fully supports IPv6, though there are a few things to keep in mind.
|
||||||
|
|
||||||
|
- **Listening on an IPv6 address**: By default, ntfy listens on `:80` (IPv4-only). If you want to listen on an IPv6 address, you need to
|
||||||
|
explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. To listen on
|
||||||
|
IPv4 and IPv6, you must run ntfy behind a reverse proxy, e.g. `listen :80; listen [::]:80;` in nginx.
|
||||||
|
- **Rate limiting:** By default, ntfy uses the `/64` subnet of the visitor's IPv6 address for rate limiting. This means that all visitors in the same `/64`
|
||||||
|
subnet are treated as one visitor. If you want to change this, you can set the `visitor-prefix-bits-ipv6` option in your `server.yml` file to a different
|
||||||
|
value (e.g. `48` for `/48` subnets). See [IPv6 considerations](#ipv6-considerations) and [IP-based rate limiting](#ip-based-rate-limiting) for more details.
|
||||||
|
- **Banning IPs with fail2ban:** By default, if you're using the `iptables-multiport` action, fail2ban bans individual IPv4 and IPv6 addresses via `iptables` and `ip6tables`. While this behavior is fine for IPv4, it is not for IPv6, because every host can technically have up to 2^64 addresses. Please ensure that your `actionban` and `actionunban` commands
|
||||||
|
support IPv6 and also ban the entire prefix (e.g. `/48`). See [Banning bad actors](#banning-bad-actors-fail2ban) for details.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
The official ntfy.sh server supports IPv6. Check out ntfy.sh's [Ansible repository](https://github.com/binwiederhier/ntfy-ansible) for examples of how to
|
||||||
|
configure [ntfy](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/ntfy), [nginx](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/nginx) and [fail2ban](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban).
|
||||||
|
|
||||||
## Health checks
|
## Health checks
|
||||||
A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.
|
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 `healthy` field is `false` the ntfy service should be considered as unhealthy.
|
If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy.
|
||||||
@@ -1390,7 +1493,9 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
|
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
|
||||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||||
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
|
||||||
|
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
|
||||||
|
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
|
||||||
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||||
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
|
||||||
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
|
||||||
@@ -1420,9 +1525,11 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
|
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
|
||||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||||
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
|
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
|
||||||
|
| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 |
|
||||||
|
| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 |
|
||||||
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
|
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
|
||||||
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||||
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||||
@@ -1518,6 +1625,7 @@ OPTIONS:
|
|||||||
--message-delay-limit value, --message_delay_limit value max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT]
|
--message-delay-limit value, --message_delay_limit value max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT]
|
||||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||||
|
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
|
||||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||||
@@ -1526,8 +1634,11 @@ OPTIONS:
|
|||||||
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
|
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
|
||||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||||
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
|
--visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4]
|
||||||
--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]
|
--visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6]
|
||||||
|
--behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||||
|
--proxy-forwarded-header value, --proxy_forwarded_header value use specified header to determine visitor IP address (for rate limiting) (default: "X-Forwarded-For") [$NTFY_PROXY_FORWARDED_HEADER]
|
||||||
|
--proxy-trusted-hosts value, --proxy_trusted_hosts value comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header [$NTFY_PROXY_TRUSTED_HOSTS]
|
||||||
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
|
--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]
|
--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]
|
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
|
||||||
@@ -1541,5 +1652,5 @@ OPTIONS:
|
|||||||
--web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
|
--web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
|
||||||
--web-push-expiry-duration value, --web_push_expiry_duration value automatically expire unused subscriptions after this time (default: "60d") [$NTFY_WEB_PUSH_EXPIRY_DURATION]
|
--web-push-expiry-duration value, --web_push_expiry_duration value automatically expire unused subscriptions after this time (default: "60d") [$NTFY_WEB_PUSH_EXPIRY_DURATION]
|
||||||
--web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value send web push warning notification after this time before expiring unused subscriptions (default: "55d") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]
|
--web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value send web push warning notification after this time before expiring unused subscriptions (default: "55d") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION]
|
||||||
--help, -h show help
|
--help, -h
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ ntfy lets you **send push notifications to your phone or desktop via scripts fro
|
|||||||
or POST requests. I use it to notify myself when scripts fail, or long-running commands complete.
|
or POST requests. I use it to notify myself when scripts fail, or long-running commands complete.
|
||||||
|
|
||||||
## Step 1: Get the app
|
## Step 1: Get the app
|
||||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"></a>
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="static/img/badge-googleplay.png"></a>
|
||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"></a>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="static/img/badge-fdroid.png"></a>
|
||||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"></a>
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="static/img/badge-appstore.png"></a>
|
||||||
|
|
||||||
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.
|
To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid.
|
||||||
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just
|
Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just
|
||||||
|
|||||||
@@ -280,8 +280,6 @@ docker run \
|
|||||||
|
|
||||||
Using docker-compose with non-root user and healthchecks enabled:
|
Using docker-compose with non-root user and healthchecks enabled:
|
||||||
```yaml
|
```yaml
|
||||||
version: "2.3"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ntfy:
|
ntfy:
|
||||||
image: binwiederhier/ntfy
|
image: binwiederhier/ntfy
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
|
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
|
||||||
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
||||||
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
||||||
|
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
||||||
|
|
||||||
## Projects + scripts
|
## Projects + scripts
|
||||||
|
|
||||||
|
|||||||
@@ -40,16 +40,16 @@ user support in Discord/Matrix/GitHub! You rock, man!
|
|||||||
**Documentation:**
|
**Documentation:**
|
||||||
|
|
||||||
* Lots of new integrations and projects. Amazing!
|
* Lots of new integrations and projects. Amazing!
|
||||||
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
|
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
|
||||||
* [UptimeObserver](https://uptimeobserver.com)
|
* [UptimeObserver](https://uptimeobserver.com)
|
||||||
* [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay)
|
* [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay)
|
||||||
* [Monibot](https://monibot.io/)
|
* [Monibot](https://monibot.io/)
|
||||||
* [Daily_Fact_Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy)
|
* [Daily_Fact_Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy)
|
||||||
* [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage)
|
* [EasyMorph](https://help.easymorph.com/doku.php?id=transformations:sendntfymessage)
|
||||||
* [ntfy-run](https://github.com/quantum5/ntfy-run)
|
* [ntfy-run](https://github.com/quantum5/ntfy-run)
|
||||||
* [Clipboard IO](https://github.com/jim3692/clipboard-io)
|
* [Clipboard IO](https://github.com/jim3692/clipboard-io)
|
||||||
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
|
* [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp)
|
||||||
* [InvaderInformant](https://github.com/patricksthannon/InvaderInformant)
|
* [InvaderInformant](https://github.com/patricksthannon/InvaderInformant)
|
||||||
* Various docs updates ([#1161](https://github.com/binwiederhier/ntfy/pull/1161), thanks to [@OneWeekNotice](https://github.com/OneWeekNotice))
|
* Various docs updates ([#1161](https://github.com/binwiederhier/ntfy/pull/1161), thanks to [@OneWeekNotice](https://github.com/OneWeekNotice))
|
||||||
* Typo in config docs ([#1177](https://github.com/binwiederhier/ntfy/pull/1177), thanks to [@hoho4190](https://github.com/hoho4190))
|
* Typo in config docs ([#1177](https://github.com/binwiederhier/ntfy/pull/1177), thanks to [@hoho4190](https://github.com/hoho4190))
|
||||||
* Typo in CLI docs ([#1172](https://github.com/binwiederhier/ntfy/pull/1172), thanks to [@anirvan](https://github.com/anirvan))
|
* Typo in CLI docs ([#1172](https://github.com/binwiederhier/ntfy/pull/1172), thanks to [@anirvan](https://github.com/anirvan))
|
||||||
@@ -1433,6 +1433,19 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
|
### ntfy server v2.13.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4))
|
||||||
|
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
|
||||||
|
* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn))
|
||||||
|
|
||||||
|
**Languages**
|
||||||
|
|
||||||
|
* Update new languages from Weblate. Thanks to all the contributors!
|
||||||
|
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|||||||
BIN
docs/static/img/badge-appstore.png
vendored
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 24 KiB |
BIN
docs/static/img/badge-fdroid.png
vendored
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 17 KiB |
BIN
docs/static/img/badge-googleplay.png
vendored
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -4,9 +4,9 @@ to receive notifications directly on your phone. Just like the server, this app
|
|||||||
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
|
on GitHub ([Android](https://github.com/binwiederhier/ntfy-android), [iOS](https://github.com/binwiederhier/ntfy-ios)). Feel free to
|
||||||
contribute, or [build your own](../develop.md).
|
contribute, or [build your own](../develop.md).
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="../../static/img/badge-googleplay.png"></a>
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img width="170" src="../../static/img/badge-googleplay.png"></a>
|
||||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="../../static/img/badge-fdroid.png"></a>
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
|
||||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="../../static/img/badge-appstore.png"></a>
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
|
||||||
|
|
||||||
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||||
|
|||||||
66
go.mod
@@ -6,7 +6,7 @@ toolchain go1.24.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.18.0 // indirect
|
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||||
cloud.google.com/go/storage v1.54.0 // indirect
|
cloud.google.com/go/storage v1.55.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/emersion/go-smtp v0.18.0
|
github.com/emersion/go-smtp v0.18.0
|
||||||
@@ -15,13 +15,13 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.28
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
github.com/olebedev/when v1.1.0
|
github.com/olebedev/when v1.1.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/urfave/cli/v2 v2.27.6
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.39.0
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.14.0
|
golang.org/x/sync v0.15.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.32.0
|
||||||
golang.org/x/time v0.11.0
|
golang.org/x/time v0.12.0
|
||||||
google.golang.org/api v0.234.0
|
google.golang.org/api v0.240.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
|
|||||||
require github.com/pkg/errors v0.9.1 // indirect
|
require github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
firebase.google.com/go/v4 v4.15.2
|
firebase.google.com/go/v4 v4.16.1
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.22.0
|
||||||
@@ -39,29 +39,29 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cel.dev/expr v0.24.0 // indirect
|
cel.dev/expr v0.24.0 // indirect
|
||||||
cloud.google.com/go v0.121.2 // indirect
|
cloud.google.com/go v0.121.3 // indirect
|
||||||
cloud.google.com/go/auth v0.16.1 // indirect
|
cloud.google.com/go/auth v0.16.2 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||||
cloud.google.com/go/iam v1.5.2 // indirect
|
cloud.google.com/go/iam v1.5.2 // indirect
|
||||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
@@ -73,32 +73,32 @@ require (
|
|||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.64.0 // indirect
|
github.com/prometheus/common v0.65.0 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||||
github.com/zeebo/errs v1.4.0 // indirect
|
github.com/zeebo/errs v1.4.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
golang.org/x/net v0.40.0 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237 // indirect
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||||
google.golang.org/grpc v1.72.1 // indirect
|
google.golang.org/grpc v1.73.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
141
go.sum
@@ -1,9 +1,9 @@
|
|||||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||||
cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg=
|
cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=
|
||||||
cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw=
|
cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
|
||||||
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
|
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
|
||||||
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
|
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||||
@@ -18,24 +18,24 @@ cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFs
|
|||||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||||
cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI+0=
|
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
|
||||||
cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE=
|
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
|
||||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||||
firebase.google.com/go/v4 v4.15.2 h1:KJtV4rAfO2CVCp40hBfVk+mqUqg7+jQKx7yOgFDnXBg=
|
firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4=
|
||||||
firebase.google.com/go/v4 v4.15.2/go.mod h1:qkD/HtSumrPMTLs0ahQrje5gTw2WKFKrzVFoqy4SbKA=
|
firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
@@ -51,8 +51,8 @@ github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1Ig
|
|||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
@@ -70,11 +70,11 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
@@ -124,16 +124,17 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
|
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||||
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
@@ -148,41 +149,43 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
|
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||||
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw=
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
|
||||||
go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k=
|
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -197,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -208,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -246,10 +249,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
@@ -258,18 +261,18 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
|||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/api v0.234.0 h1:d3sAmYq3E9gdr2mpmiWGbm9pHsA/KJmyiLkwKfHBqU4=
|
google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU=
|
||||||
google.golang.org/api v0.234.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
|
google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||||
google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237 h1:2zGWyk04EwQ3mmV4dd4M4U7P/igHi5p7CBJEg1rI6A8=
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||||
google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237/go.mod h1:LhI4bRmX3rqllzQ+BGneexULkEjBf2gsAfkbeCA8IbU=
|
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
|
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||||
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const (
|
|||||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||||
|
DefaultVisitorPrefixBitsIPv4 = 32 // Use the entire IPv4 address for rate limiting
|
||||||
|
DefaultVisitorPrefixBitsIPv6 = 64 // Use /64 for IPv6 rate limiting
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -133,7 +135,7 @@ type Config struct {
|
|||||||
VisitorAttachmentDailyBandwidthLimit int64
|
VisitorAttachmentDailyBandwidthLimit int64
|
||||||
VisitorRequestLimitBurst int
|
VisitorRequestLimitBurst int
|
||||||
VisitorRequestLimitReplenish time.Duration
|
VisitorRequestLimitReplenish time.Duration
|
||||||
VisitorRequestExemptIPAddrs []netip.Prefix
|
VisitorRequestExemptPrefixes []netip.Prefix
|
||||||
VisitorMessageDailyLimit int
|
VisitorMessageDailyLimit int
|
||||||
VisitorEmailLimitBurst int
|
VisitorEmailLimitBurst int
|
||||||
VisitorEmailLimitReplenish time.Duration
|
VisitorEmailLimitReplenish time.Duration
|
||||||
@@ -141,9 +143,13 @@ type Config struct {
|
|||||||
VisitorAccountCreationLimitReplenish time.Duration
|
VisitorAccountCreationLimitReplenish time.Duration
|
||||||
VisitorAuthFailureLimitBurst int
|
VisitorAuthFailureLimitBurst int
|
||||||
VisitorAuthFailureLimitReplenish time.Duration
|
VisitorAuthFailureLimitReplenish time.Duration
|
||||||
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
||||||
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
|
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
|
||||||
BehindProxy bool
|
VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32)
|
||||||
|
VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64)
|
||||||
|
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
|
||||||
|
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
|
||||||
|
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
StripePriceCacheDuration time.Duration
|
StripePriceCacheDuration time.Duration
|
||||||
@@ -153,7 +159,6 @@ type Config struct {
|
|||||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||||
EnableMetrics bool
|
EnableMetrics bool
|
||||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||||
Version string // injected by App
|
|
||||||
WebPushPrivateKey string
|
WebPushPrivateKey string
|
||||||
WebPushPublicKey string
|
WebPushPublicKey string
|
||||||
WebPushFile string
|
WebPushFile string
|
||||||
@@ -161,6 +166,7 @@ type Config struct {
|
|||||||
WebPushStartupQueries string
|
WebPushStartupQueries string
|
||||||
WebPushExpiryDuration time.Duration
|
WebPushExpiryDuration time.Duration
|
||||||
WebPushExpiryWarningDuration time.Duration
|
WebPushExpiryWarningDuration time.Duration
|
||||||
|
Version string // injected by App
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
@@ -218,11 +224,12 @@ func NewConfig() *Config {
|
|||||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||||
TotalAttachmentSizeLimit: 0,
|
TotalAttachmentSizeLimit: 0,
|
||||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||||
|
VisitorSubscriberRateLimiting: false,
|
||||||
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
||||||
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
||||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||||
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
VisitorRequestExemptPrefixes: make([]netip.Prefix, 0),
|
||||||
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
||||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
@@ -231,8 +238,10 @@ func NewConfig() *Config {
|
|||||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
VisitorSubscriberRateLimiting: false,
|
VisitorPrefixBitsIPv4: DefaultVisitorPrefixBitsIPv4, // Default: use full IPv4 address
|
||||||
BehindProxy: false,
|
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
|
||||||
|
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
|
||||||
|
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
|
||||||
StripeSecretKey: "",
|
StripeSecretKey: "",
|
||||||
StripeWebhookKey: "",
|
StripeWebhookKey: "",
|
||||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||||||
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
|
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
|
||||||
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
|
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
|
||||||
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
|
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
|
||||||
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
|
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
|
||||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
||||||
} else if email != "" && !vrate.EmailAllowed() {
|
} else if email != "" && !vrate.EmailAllowed() {
|
||||||
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
||||||
@@ -1936,8 +1936,8 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun
|
|||||||
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
|
||||||
// that subsequent logging calls still have a visitor context.
|
// that subsequent logging calls still have a visitor context.
|
||||||
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||||
// Read "Authorization" header value, and exit out early if it's not set
|
// Read the "Authorization" header value and exit out early if it's not set
|
||||||
ip := extractIPAddress(r, s.config.BehindProxy)
|
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||||
vip := s.visitor(ip, nil)
|
vip := s.visitor(ip, nil)
|
||||||
if s.userManager == nil {
|
if s.userManager == nil {
|
||||||
return vip, nil
|
return vip, nil
|
||||||
@@ -2012,7 +2012,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ip := extractIPAddress(r, s.config.BehindProxy)
|
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
|
||||||
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
|
go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{
|
||||||
LastAccess: time.Now(),
|
LastAccess: time.Now(),
|
||||||
LastOrigin: ip,
|
LastOrigin: ip,
|
||||||
@@ -2023,7 +2023,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us
|
|||||||
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
|
func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
id := visitorID(ip, user)
|
id := visitorID(ip, user, s.config)
|
||||||
v, exists := s.visitors[id]
|
v, exists := s.visitors[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)
|
s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)
|
||||||
|
|||||||
@@ -95,13 +95,23 @@
|
|||||||
# auth-default-access: "read-write"
|
# auth-default-access: "read-write"
|
||||||
# auth-startup-queries:
|
# auth-startup-queries:
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
||||||
# instead of the remote address of the connection.
|
# the visitor IP address instead of the remote address of the connection.
|
||||||
#
|
#
|
||||||
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate limited
|
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate-limited
|
||||||
# as if they are one.
|
# as if they are one.
|
||||||
#
|
#
|
||||||
|
# - behind-proxy makes it so that the real visitor IP address is extracted from the header defined in
|
||||||
|
# proxy-forwarded-header. Without this, the remote address of the incoming connection is used.
|
||||||
|
# - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4),
|
||||||
|
# a comma-separated list of IP addresses (e.g. "1.2.3.4, 5.6.7.8"), or an RFC 7239-style header (e.g. "for=1.2.3.4;by=proxy.example.com, for=5.6.7.8").
|
||||||
|
# - proxy-trusted-hosts is a comma-separated list of IP addresses, hostnames or CIDRs that are removed from the forwarded header
|
||||||
|
# to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to
|
||||||
|
# the forwarded header.
|
||||||
|
#
|
||||||
# behind-proxy: false
|
# behind-proxy: false
|
||||||
|
# proxy-forwarded-header: "X-Forwarded-For"
|
||||||
|
# proxy-trusted-hosts:
|
||||||
|
|
||||||
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||||
# are "attachment-cache-dir" and "base-url".
|
# are "attachment-cache-dir" and "base-url".
|
||||||
@@ -138,7 +148,7 @@
|
|||||||
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
|
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
|
||||||
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
|
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
|
||||||
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
|
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
|
||||||
# $topic@ntfy.sh will be accepted (which may obviously be a spam problem).
|
# $topic@ntfy.sh will be accepted (which may be a spam problem).
|
||||||
#
|
#
|
||||||
# smtp-server-listen:
|
# smtp-server-listen:
|
||||||
# smtp-server-domain:
|
# smtp-server-domain:
|
||||||
@@ -282,6 +292,18 @@
|
|||||||
# visitor-email-limit-burst: 16
|
# visitor-email-limit-burst: 16
|
||||||
# visitor-email-limit-replenish: "1h"
|
# visitor-email-limit-replenish: "1h"
|
||||||
|
|
||||||
|
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
|
||||||
|
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
|
||||||
|
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
|
||||||
|
#
|
||||||
|
# This is used to group visitors by their IP address or subnet. For example, if you set visitor-prefix-bits-ipv4 to 24,
|
||||||
|
# all visitors in the 1.2.3.0/24 network are treated as one.
|
||||||
|
#
|
||||||
|
# By default, ntfy uses the full IPv4 address (32 bits) and the /64 subnet of the IPv6 address (64 bits).
|
||||||
|
#
|
||||||
|
# visitor-prefix-bits-ipv4: 32
|
||||||
|
# visitor-prefix-bits-ipv6: 64
|
||||||
|
|
||||||
# Rate limiting: Attachment size and bandwidth limits per visitor:
|
# Rate limiting: Attachment size and bandwidth limits per visitor:
|
||||||
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
|
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
|
||||||
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
|
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const (
|
|||||||
|
|
||||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
|
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
} else if !v.RequestAllowed() {
|
} else if !v.RequestAllowed() {
|
||||||
return errHTTPTooManyRequestsLimitRequests
|
return errHTTPTooManyRequestsLimitRequests
|
||||||
@@ -40,7 +40,7 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
|
|||||||
contextRateVisitor: vrate,
|
contextRateVisitor: vrate,
|
||||||
contextTopic: t,
|
contextTopic: t,
|
||||||
})
|
})
|
||||||
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
|
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
} else if !vrate.RequestAllowed() {
|
} else if !vrate.RequestAllowed() {
|
||||||
return errHTTPTooManyRequestsLimitRequests
|
return errHTTPTooManyRequestsLimitRequests
|
||||||
|
|||||||
@@ -1169,7 +1169,7 @@ func (t *testMailer) Count() int {
|
|||||||
return t.count
|
return t.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
func TestServer_PublishTooManyRequests_Defaults(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
for i := 0; i < 60; i++ {
|
for i := 0; i < 60; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
@@ -1179,10 +1179,53 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
|||||||
require.Equal(t, 429, response.Code)
|
require.Equal(t, 429, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
overrideRemoteAddr1 := func(r *http.Request) {
|
||||||
|
r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234"
|
||||||
|
}
|
||||||
|
overrideRemoteAddr2 := func(r *http.Request) {
|
||||||
|
r.RemoteAddr = "[2001:db8:9999:8888:2::1]:1234" // Same /64
|
||||||
|
}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorRequestLimitBurst = 6
|
||||||
|
c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
overrideRemoteAddr1 := func(r *http.Request) {
|
||||||
|
r.RemoteAddr = "[2001:db8:9999::1]:1234"
|
||||||
|
}
|
||||||
|
overrideRemoteAddr2 := func(r *http.Request) {
|
||||||
|
r.RemoteAddr = "[2001:db8:9999::2]:1234" // Same /48
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1)
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 3
|
c.VisitorRequestLimitBurst = 3
|
||||||
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
for i := 0; i < 5; i++ { // > 3
|
for i := 0; i < 5; i++ { // > 3
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
@@ -1190,11 +1233,25 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
|
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorRequestLimitBurst = 3
|
||||||
|
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")}
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
overrideRemoteAddr := func(r *http.Request) {
|
||||||
|
r.RemoteAddr = "[2001:db8:9999::1]:1234"
|
||||||
|
}
|
||||||
|
for i := 0; i < 5; i++ { // > 3
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 10
|
c.VisitorRequestLimitBurst = 10
|
||||||
c.VisitorMessageDailyLimit = 4
|
c.VisitorMessageDailyLimit = 4
|
||||||
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
for i := 0; i < 8; i++ { // 4
|
for i := 0; i < 8; i++ { // 4
|
||||||
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
@@ -1202,7 +1259,7 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 60
|
c.VisitorRequestLimitBurst = 60
|
||||||
@@ -2200,7 +2257,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
|||||||
c.BehindProxy = true
|
c.BehindProxy = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11:1234"
|
||||||
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
||||||
v, err := s.maybeAuthenticate(r)
|
v, err := s.maybeAuthenticate(r)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -2212,7 +2269,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
|
|||||||
c.BehindProxy = true
|
c.BehindProxy = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11:1234"
|
||||||
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||||
v, err := s.maybeAuthenticate(r)
|
v, err := s.maybeAuthenticate(r)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -2224,13 +2281,67 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
|||||||
c.BehindProxy = true
|
c.BehindProxy = true
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11:1234"
|
||||||
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
||||||
v, err := s.maybeAuthenticate(r)
|
v, err := s.maybeAuthenticate(r)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "234.5.2.1", v.ip.String())
|
require.Equal(t, "234.5.2.1", v.ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.ProxyForwardedHeader = "X-Client-IP"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
|
r.RemoteAddr = "8.9.10.11:1234"
|
||||||
|
r.Header.Set("X-Client-IP", "1.2.3.4")
|
||||||
|
v, err := s.maybeAuthenticate(r)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "1.2.3.4", v.ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.ProxyForwardedHeader = "X-Client-IP"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
|
r.RemoteAddr = "[2001:db8:9999::1]:1234"
|
||||||
|
r.Header.Set("X-Client-IP", "2001:db8:7777::1")
|
||||||
|
v, err := s.maybeAuthenticate(r)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "2001:db8:7777::1", v.ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.ProxyForwardedHeader = "Forwarded"
|
||||||
|
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
|
r.RemoteAddr = "8.9.10.11:1234"
|
||||||
|
r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4")
|
||||||
|
v, err := s.maybeAuthenticate(r)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "5.6.7.8", v.ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
c.ProxyForwardedHeader = "Forwarded"
|
||||||
|
c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")}
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
|
r.RemoteAddr = "[2001:db8:2222::1]:1234"
|
||||||
|
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
|
||||||
|
v, err := s.maybeAuthenticate(r)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "2001:db8:3333::1", v.ip.String())
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
count := 50000
|
count := 50000
|
||||||
@@ -2320,7 +2431,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
|||||||
|
|
||||||
// "Register" visitor 1.2.3.4 to topic "upAAAAAAAAAAAA" as a rate limit visitor
|
// "Register" visitor 1.2.3.4 to topic "upAAAAAAAAAAAA" as a rate limit visitor
|
||||||
subscriber1Fn := func(r *http.Request) {
|
subscriber1Fn := func(r *http.Request) {
|
||||||
r.RemoteAddr = "1.2.3.4"
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
}
|
}
|
||||||
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn)
|
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -2329,7 +2440,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
|||||||
|
|
||||||
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
|
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
|
||||||
subscriber2Fn := func(r *http.Request) {
|
subscriber2Fn := func(r *http.Request) {
|
||||||
r.RemoteAddr = "8.7.7.1"
|
r.RemoteAddr = "8.7.7.1:1234"
|
||||||
}
|
}
|
||||||
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
|
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -2372,7 +2483,7 @@ func TestServer_SubscriberRateLimiting_NotWrongTopic(t *testing.T) {
|
|||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
subscriberFn := func(r *http.Request) {
|
subscriberFn := func(r *http.Request) {
|
||||||
r.RemoteAddr = "1.2.3.4"
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
}
|
}
|
||||||
rr := request(t, s, "GET", "/alerts,upAAAAAAAAAAAA,upBBBBBBBBBBBB/json?poll=1", "", nil, subscriberFn)
|
rr := request(t, s, "GET", "/alerts,upAAAAAAAAAAAA,upBBBBBBBBBBBB/json?poll=1", "", nil, subscriberFn)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -2392,7 +2503,7 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
|
|||||||
|
|
||||||
// Registering visitor 1.2.3.4 to topic has no effect
|
// Registering visitor 1.2.3.4 to topic has no effect
|
||||||
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, func(r *http.Request) {
|
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, func(r *http.Request) {
|
||||||
r.RemoteAddr = "1.2.3.4"
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Equal(t, "", rr.Body.String())
|
require.Equal(t, "", rr.Body.String())
|
||||||
@@ -2400,7 +2511,7 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
|
|||||||
|
|
||||||
// Registering visitor 8.7.7.1 to topic has no effect
|
// 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) {
|
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) {
|
||||||
r.RemoteAddr = "8.7.7.1"
|
r.RemoteAddr = "8.7.7.1:1234"
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Equal(t, "", rr.Body.String())
|
require.Equal(t, "", rr.Body.String())
|
||||||
@@ -2426,7 +2537,7 @@ func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
|||||||
// "Register" 5 different UnifiedPush visitors
|
// "Register" 5 different UnifiedPush visitors
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
subscriberFn := func(r *http.Request) {
|
subscriberFn := func(r *http.Request) {
|
||||||
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
|
r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1)
|
||||||
}
|
}
|
||||||
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn)
|
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -2450,7 +2561,7 @@ func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
|||||||
// "Register" 5 different UnifiedPush visitors
|
// "Register" 5 different UnifiedPush visitors
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) {
|
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) {
|
||||||
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
|
r.RemoteAddr = fmt.Sprintf("1.2.3.%d:1234", i+1)
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
}
|
}
|
||||||
@@ -2477,7 +2588,7 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
|||||||
|
|
||||||
// "Register" rate visitor
|
// "Register" rate visitor
|
||||||
subscriberFn := func(r *http.Request) {
|
subscriberFn := func(r *http.Request) {
|
||||||
r.RemoteAddr = "1.2.3.4"
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
}
|
}
|
||||||
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn)
|
rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -2516,7 +2627,7 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *t
|
|||||||
// - "up123456789012": Allowed, because no ACLs and nobody owns the topic
|
// - "up123456789012": Allowed, because no ACLs and nobody owns the topic
|
||||||
// - "announcements": NOT allowed, because it has read-only permissions for everyone
|
// - "announcements": NOT allowed, because it has read-only permissions for everyone
|
||||||
rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) {
|
rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) {
|
||||||
r.RemoteAddr = "1.2.3.4"
|
r.RemoteAddr = "1.2.3.4:1234"
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String())
|
require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String())
|
||||||
@@ -2958,7 +3069,7 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r.RemoteAddr = "9.9.9.9" // Used for tests
|
r.RemoteAddr = "9.9.9.9:1234" // Used for tests
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
r.Header.Set(k, v)
|
r.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -18,6 +16,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -191,9 +192,9 @@ func (s *smtpSession) publishMessage(m *message) error {
|
|||||||
// Call HTTP handler with fake HTTP request
|
// Call HTTP handler with fake HTTP request
|
||||||
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||||
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
||||||
req.RequestURI = "/" + m.Topic // just for the logs
|
req.RequestURI = "/" + m.Topic // just for the logs
|
||||||
req.RemoteAddr = remoteAddr // rate limiting!!
|
req.RemoteAddr = remoteAddr // rate limiting!!
|
||||||
req.Header.Set("X-Forwarded-For", remoteAddr)
|
req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
114
server/util.go
@@ -4,18 +4,30 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mimeDecoder mime.WordDecoder
|
mimeDecoder mime.WordDecoder
|
||||||
|
|
||||||
|
// priorityHeaderIgnoreRegex matches specific patterns of the "Priority" header (RFC 9218), so that it can be ignored
|
||||||
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
|
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
|
||||||
|
|
||||||
|
// forwardedHeaderRegex parses IPv4 and IPv6 addresses from the "Forwarded" header (RFC 7239)
|
||||||
|
// IPv6 addresses in Forwarded header are enclosed in square brackets. The port is optional.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// for="1.2.3.4"
|
||||||
|
// for="[2001:db8::1]"; for=1.2.3.4:8080, by=phil
|
||||||
|
// for="1.2.3.4:8080"
|
||||||
|
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9a-f:]+])(?::\d+)?"?`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
@@ -34,15 +46,11 @@ func toBool(value string) bool {
|
|||||||
return value == "1" || value == "yes" || value == "true"
|
return value == "1" || value == "yes" || value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) {
|
func readCommaSeparatedParam(r *http.Request, names ...string) []string {
|
||||||
paramStr := readParam(r, names...)
|
if paramStr := readParam(r, names...); paramStr != "" {
|
||||||
if paramStr != "" {
|
return util.Map(util.SplitNoEmpty(paramStr, ","), strings.TrimSpace)
|
||||||
params = make([]string, 0)
|
|
||||||
for _, s := range util.SplitNoEmpty(paramStr, ",") {
|
|
||||||
params = append(params, strings.TrimSpace(s))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return params
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readParam(r *http.Request, names ...string) string {
|
func readParam(r *http.Request, names ...string) string {
|
||||||
@@ -73,34 +81,68 @@ func readQueryParam(r *http.Request, names ...string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
// extractIPAddress extracts the IP address of the visitor from the request,
|
||||||
remoteAddr := r.RemoteAddr
|
// either from the TCP socket or from a proxy header.
|
||||||
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedPrefixes []netip.Prefix) netip.Addr {
|
||||||
ip := addrPort.Addr()
|
if behindProxy && proxyForwardedHeader != "" {
|
||||||
|
if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedPrefixes); err == nil {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
// Fall back to the remote address if the header is not found or invalid
|
||||||
|
}
|
||||||
|
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified
|
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", r.RemoteAddr)
|
||||||
ip, err = netip.ParseAddr(remoteAddr)
|
return netip.IPv4Unspecified()
|
||||||
if err != nil {
|
}
|
||||||
ip = netip.IPv4Unspecified()
|
return addrPort.Addr()
|
||||||
if remoteAddr != "@" && !behindProxy { // RemoteAddr is @ when unix socket is used
|
}
|
||||||
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr)
|
|
||||||
|
// extractIPAddressFromHeader extracts the last IP address from the specified header.
|
||||||
|
//
|
||||||
|
// It supports multiple formats:
|
||||||
|
// - single IP address
|
||||||
|
// - comma-separated list
|
||||||
|
// - RFC 7239-style list (Forwarded header)
|
||||||
|
//
|
||||||
|
// If there are multiple addresses, we first remove the trusted IP addresses from the list, and
|
||||||
|
// then take the right-most address in the list (as this is the one added by our proxy server).
|
||||||
|
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
||||||
|
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedPrefixes []netip.Prefix) (netip.Addr, error) {
|
||||||
|
value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))
|
||||||
|
if value == "" {
|
||||||
|
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
|
||||||
|
}
|
||||||
|
// Extract valid addresses
|
||||||
|
addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
|
||||||
|
var validAddrs []netip.Addr
|
||||||
|
for _, addrStr := range addrsStrs {
|
||||||
|
// Handle Forwarded header with for="[IPv6]" or for="IPv4"
|
||||||
|
if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
|
||||||
|
addrRaw := m[1]
|
||||||
|
if strings.HasPrefix(addrRaw, "[") && strings.HasSuffix(addrRaw, "]") {
|
||||||
|
addrRaw = addrRaw[1 : len(addrRaw)-1]
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(addrRaw); err == nil {
|
||||||
|
validAddrs = append(validAddrs, addr)
|
||||||
|
}
|
||||||
|
} else if addr, err := netip.ParseAddr(addrStr); err == nil {
|
||||||
|
validAddrs = append(validAddrs, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter out proxy addresses
|
||||||
|
clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {
|
||||||
|
for _, prefix := range trustedPrefixes {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return false // Address is in the trusted range, ignore it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if len(clientAddrs) == 0 {
|
||||||
|
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)
|
||||||
}
|
}
|
||||||
if behindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
|
return clientAddrs[len(clientAddrs)-1], nil
|
||||||
// X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy,
|
|
||||||
// only the right-most address can be trusted (as this is the one added by our proxy server).
|
|
||||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
|
||||||
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
|
||||||
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
|
|
||||||
if err != nil {
|
|
||||||
logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip)
|
|
||||||
// Fall back to regular remote address if X-Forwarded-For is damaged
|
|
||||||
} else {
|
|
||||||
ip = realIP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ip
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||||
@@ -133,7 +175,7 @@ func fromContext[T any](r *http.Request, key contextKey) (T, error) {
|
|||||||
|
|
||||||
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
|
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
|
||||||
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
|
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
|
||||||
// to ignore new HTTP "Priority" header.
|
// to ignore the new HTTP "Priority" header.
|
||||||
func maybeDecodeHeader(name, value string) string {
|
func maybeDecodeHeader(name, value string) string {
|
||||||
decoded, err := mimeDecoder.DecodeHeader(value)
|
decoded, err := mimeDecoder.DecodeHeader(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,7 +184,7 @@ func maybeDecodeHeader(name, value string) string {
|
|||||||
return maybeIgnoreSpecialHeader(name, decoded)
|
return maybeIgnoreSpecialHeader(name, decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeIgnoreSpecialHeader ignores new HTTP "Priority" header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
|
// maybeIgnoreSpecialHeader ignores the new HTTP "Priority" header (RFC 9218, see https://datatracker.ietf.org/doc/html/rfc9218)
|
||||||
//
|
//
|
||||||
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
|
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
|
||||||
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
|
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"heckel.io/ntfy/v2/user"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReadBoolParam(t *testing.T) {
|
func TestReadBoolParam(t *testing.T) {
|
||||||
@@ -88,3 +91,74 @@ func TestMaybeDecodeHeaders(t *testing.T) {
|
|||||||
r.Header.Set("X-Priority", "5") // ntfy priority header
|
r.Header.Set("X-Priority", "5") // ntfy priority header
|
||||||
require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p"))
|
require.Equal(t, "5", readHeaderParam(r, "x-priority", "priority", "p"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "10.0.0.1:1234"
|
||||||
|
r.Header.Set("X-Forwarded-For", " 1.2.3.4 , 5.6.7.8")
|
||||||
|
r.Header.Set("X-Client-IP", "9.10.11.12")
|
||||||
|
r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1")
|
||||||
|
r.Header.Set("Forwarded", "for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1")
|
||||||
|
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
|
||||||
|
|
||||||
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String())
|
||||||
|
require.Equal(t, "13.14.15.16", extractIPAddress(r, true, "X-Real-IP", trustedProxies).String())
|
||||||
|
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
|
||||||
|
require.Equal(t, "10.0.0.1", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress_UnixSocket(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "@"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1")
|
||||||
|
r.Header.Set("Forwarded", "by=bla.example.com;for=17.18.19.20")
|
||||||
|
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")}
|
||||||
|
|
||||||
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
|
||||||
|
require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress_MixedIPv4IPv6(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
|
||||||
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 2001:db8:abcd::2, 5.6.7.8")
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")}
|
||||||
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractIPAddress_TrustedIPv6Prefix(t *testing.T) {
|
||||||
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
|
r.RemoteAddr = "[2001:db8:abcd::1]:1234"
|
||||||
|
r.Header.Set("X-Forwarded-For", "2001:db8:aaaa::1, 2001:db8:aaaa::2, 2001:db8:abcd:2::3")
|
||||||
|
trustedProxies := []netip.Prefix{netip.MustParsePrefix("2001:db8:aaaa::/48")}
|
||||||
|
require.Equal(t, "2001:db8:abcd:2::3", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisitorID(t *testing.T) {
|
||||||
|
confWithDefaults := &Config{
|
||||||
|
VisitorPrefixBitsIPv4: 32,
|
||||||
|
VisitorPrefixBitsIPv6: 64,
|
||||||
|
}
|
||||||
|
confWithShortenedPrefixes := &Config{
|
||||||
|
VisitorPrefixBitsIPv4: 16,
|
||||||
|
VisitorPrefixBitsIPv6: 56,
|
||||||
|
}
|
||||||
|
userWithTier := &user.User{
|
||||||
|
ID: "u_123",
|
||||||
|
Tier: &user.Tier{},
|
||||||
|
}
|
||||||
|
require.Equal(t, "ip:1.2.3.4", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithDefaults))
|
||||||
|
require.Equal(t, "ip:2a01:599:b26:2397::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithDefaults))
|
||||||
|
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:1::1"), nil, confWithDefaults))
|
||||||
|
require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:2::1"), nil, confWithDefaults))
|
||||||
|
|
||||||
|
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("1.2.3.4"), userWithTier, confWithDefaults))
|
||||||
|
require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), userWithTier, confWithDefaults))
|
||||||
|
|
||||||
|
require.Equal(t, "ip:1.2.0.0", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithShortenedPrefixes))
|
||||||
|
require.Equal(t, "ip:2a01:599:b26:2300::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithShortenedPrefixes))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/v2/log"
|
|
||||||
"heckel.io/ntfy/v2/user"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ func (v *visitor) Context() log.Context {
|
|||||||
func (v *visitor) contextNoLock() log.Context {
|
func (v *visitor) contextNoLock() log.Context {
|
||||||
info := v.infoLightNoLock()
|
info := v.infoLightNoLock()
|
||||||
fields := log.Context{
|
fields := log.Context{
|
||||||
"visitor_id": visitorID(v.ip, v.user),
|
"visitor_id": visitorID(v.ip, v.user, v.config),
|
||||||
"visitor_ip": v.ip.String(),
|
"visitor_ip": v.ip.String(),
|
||||||
"visitor_seen": util.FormatTime(v.seen),
|
"visitor_seen": util.FormatTime(v.seen),
|
||||||
"visitor_messages": info.Stats.Messages,
|
"visitor_messages": info.Stats.Messages,
|
||||||
@@ -524,9 +524,15 @@ func dailyLimitToRate(limit int64) rate.Limit {
|
|||||||
return rate.Limit(limit) * rate.Every(oneDay)
|
return rate.Limit(limit) * rate.Every(oneDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
func visitorID(ip netip.Addr, u *user.User) string {
|
// visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6
|
||||||
|
func visitorID(ip netip.Addr, u *user.User, conf *Config) string {
|
||||||
if u != nil && u.Tier != nil {
|
if u != nil && u.Tier != nil {
|
||||||
return fmt.Sprintf("user:%s", u.ID)
|
return fmt.Sprintf("user:%s", u.ID)
|
||||||
}
|
}
|
||||||
|
if ip.Is4() {
|
||||||
|
ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked().Addr()
|
||||||
|
} else if ip.Is6() {
|
||||||
|
ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked().Addr()
|
||||||
|
}
|
||||||
return fmt.Sprintf("ip:%s", ip.String())
|
return fmt.Sprintf("ip:%s", ip.String())
|
||||||
}
|
}
|
||||||
|
|||||||
27
util/util.go
@@ -17,10 +17,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
|
|
||||||
"github.com/gabriel-vasile/mimetype"
|
"github.com/gabriel-vasile/mimetype"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -99,12 +98,26 @@ func SplitKV(s string, sep string) (key string, value string) {
|
|||||||
return "", strings.TrimSpace(kv[0])
|
return "", strings.TrimSpace(kv[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// LastString returns the last string in a slice, or def if s is empty
|
// Map applies a function to each element of a slice and returns a new slice with the results
|
||||||
func LastString(s []string, def string) string {
|
// Example: Map([]int{1, 2, 3}, func(i int) int { return i * 2 }) -> []int{2, 4, 6}
|
||||||
if len(s) == 0 {
|
func Map[T any, U any](slice []T, f func(T) U) []U {
|
||||||
return def
|
result := make([]U, len(slice))
|
||||||
|
for i, v := range slice {
|
||||||
|
result[i] = f(v)
|
||||||
}
|
}
|
||||||
return s[len(s)-1]
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter returns a new slice containing only the elements of the original slice for which the
|
||||||
|
// given function returns true.
|
||||||
|
func Filter[T any](slice []T, f func(T) bool) []T {
|
||||||
|
result := make([]T, 0)
|
||||||
|
for _, v := range slice {
|
||||||
|
if f(v) {
|
||||||
|
result = append(result, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// RandomString returns a random string with a given length
|
// RandomString returns a random string with a given length
|
||||||
|
|||||||
@@ -167,11 +167,6 @@ func TestSplitKV(t *testing.T) {
|
|||||||
require.Equal(t, "value=with=separator", value)
|
require.Equal(t, "value=with=separator", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLastString(t *testing.T) {
|
|
||||||
require.Equal(t, "last", LastString([]string{"first", "second", "last"}, "default"))
|
|
||||||
require.Equal(t, "default", LastString([]string{}, "default"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQuoteCommand(t *testing.T) {
|
func TestQuoteCommand(t *testing.T) {
|
||||||
require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
|
require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
|
||||||
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
|
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
|
||||||
|
|||||||
992
web/package-lock.json
generated
7
web/public/static/langs/ca.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"nav_button_documentation": "Documentació",
|
||||||
|
"action_bar_profile_title": "Perfil",
|
||||||
|
"action_bar_settings": "Configuració",
|
||||||
|
"action_bar_account": "Compte",
|
||||||
|
"common_add": "Afegir"
|
||||||
|
}
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
"notifications_attachment_open_title": "Gehe zu {{url}}",
|
"notifications_attachment_open_title": "Gehe zu {{url}}",
|
||||||
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
|
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
|
||||||
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
|
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
|
||||||
"alert_notification_permission_required_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
|
"alert_notification_permission_required_description": "Browser erlauben, Desktop-Benachrichtigungen anzuzeigen",
|
||||||
"notifications_tags": "Tags",
|
"notifications_tags": "Tags",
|
||||||
"message_bar_type_message": "Gib hier eine Nachricht ein",
|
"message_bar_type_message": "Gib hier eine Nachricht ein",
|
||||||
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
|
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
|
||||||
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
|
"alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt",
|
||||||
"alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt",
|
"alert_not_supported_description": "Benachrichtigungen werden von deinem Browser nicht unterstützt",
|
||||||
"action_bar_settings": "Einstellungen",
|
"action_bar_settings": "Einstellungen",
|
||||||
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
|
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
|
||||||
"alert_notification_permission_required_button": "Jetzt erlauben",
|
"alert_notification_permission_required_button": "Jetzt erlauben",
|
||||||
@@ -208,11 +208,11 @@
|
|||||||
"action_bar_change_display_name": "Anzeigenamen ändern",
|
"action_bar_change_display_name": "Anzeigenamen ändern",
|
||||||
"action_bar_reservation_add": "Thema reservieren",
|
"action_bar_reservation_add": "Thema reservieren",
|
||||||
"action_bar_reservation_edit": "Reservierung ändern",
|
"action_bar_reservation_edit": "Reservierung ändern",
|
||||||
"action_bar_reservation_delete": "Reservierung löschen",
|
"action_bar_reservation_delete": "Reservierung entfernen",
|
||||||
"action_bar_reservation_limit_reached": "Grenze erreicht",
|
"action_bar_reservation_limit_reached": "Grenze erreicht",
|
||||||
"action_bar_profile_title": "Profil",
|
"action_bar_profile_title": "Profil",
|
||||||
"action_bar_profile_settings": "Einstellungen",
|
"action_bar_profile_settings": "Einstellungen",
|
||||||
"action_bar_profile_logout": "Abmelden",
|
"action_bar_profile_logout": "Ausloggen",
|
||||||
"action_bar_sign_in": "Anmelden",
|
"action_bar_sign_in": "Anmelden",
|
||||||
"signup_form_password": "Kennwort",
|
"signup_form_password": "Kennwort",
|
||||||
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
|
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
|
||||||
@@ -382,7 +382,7 @@
|
|||||||
"account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt",
|
"account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt",
|
||||||
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag",
|
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag",
|
||||||
"action_bar_mute_notifications": "Benachrichtigungen stummschalten",
|
"action_bar_mute_notifications": "Benachrichtigungen stummschalten",
|
||||||
"action_bar_unmute_notifications": "Benachrichtigungen laut schalten",
|
"action_bar_unmute_notifications": "Stummschaltung von Benachrichtigungen aufheben",
|
||||||
"alert_notification_permission_denied_title": "Benachrichtigungen sind blockiert",
|
"alert_notification_permission_denied_title": "Benachrichtigungen sind blockiert",
|
||||||
"alert_notification_permission_denied_description": "Bitte reaktiviere diese in deinem Browser",
|
"alert_notification_permission_denied_description": "Bitte reaktiviere diese in deinem Browser",
|
||||||
"notifications_actions_failed_notification": "Aktion nicht erfolgreich",
|
"notifications_actions_failed_notification": "Aktion nicht erfolgreich",
|
||||||
@@ -390,8 +390,8 @@
|
|||||||
"alert_notification_ios_install_required_description": "Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren",
|
"alert_notification_ios_install_required_description": "Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren",
|
||||||
"subscribe_dialog_subscribe_use_another_background_info": "Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist",
|
"subscribe_dialog_subscribe_use_another_background_info": "Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist",
|
||||||
"publish_dialog_checkbox_markdown": "Als Markdown formatieren",
|
"publish_dialog_checkbox_markdown": "Als Markdown formatieren",
|
||||||
"prefs_notifications_web_push_title": "Hintergrund-Benachrichtigungen",
|
"prefs_notifications_web_push_title": "Hintergrundbenachrichtigungen",
|
||||||
"prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen wenn die Web App läuft (über WebSocket)",
|
"prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen, wenn die Web App geöffnet ist (via WebSocket)",
|
||||||
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
|
"prefs_notifications_web_push_enabled": "Aktiviert für {{server}}",
|
||||||
"prefs_notifications_web_push_disabled": "Deaktiviert",
|
"prefs_notifications_web_push_disabled": "Deaktiviert",
|
||||||
"prefs_appearance_theme_title": "Thema",
|
"prefs_appearance_theme_title": "Thema",
|
||||||
@@ -401,7 +401,7 @@
|
|||||||
"error_boundary_button_reload_ntfy": "ntfy neu laden",
|
"error_boundary_button_reload_ntfy": "ntfy neu laden",
|
||||||
"web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert",
|
"web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert",
|
||||||
"web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten",
|
"web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten",
|
||||||
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom server empfangen",
|
"web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen",
|
||||||
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest.",
|
"web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest",
|
||||||
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht läuft (über Web Push)"
|
"prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,5 +99,176 @@
|
|||||||
"account_tokens_table_create_token_button": "Loo ligipääsuks vajalik tunnusluba",
|
"account_tokens_table_create_token_button": "Loo ligipääsuks vajalik tunnusluba",
|
||||||
"account_tokens_dialog_title_create": "Loo ligipääsuks vajalik tunnusluba",
|
"account_tokens_dialog_title_create": "Loo ligipääsuks vajalik tunnusluba",
|
||||||
"account_tokens_dialog_title_edit": "Muuda ligipääsuks vajalikku tunnusluba",
|
"account_tokens_dialog_title_edit": "Muuda ligipääsuks vajalikku tunnusluba",
|
||||||
"account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba"
|
"account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba",
|
||||||
|
"subscribe_dialog_login_password_label": "Salasõna",
|
||||||
|
"publish_dialog_filename_label": "Failinimi",
|
||||||
|
"prefs_reservations_table_access_header": "Ligipääs",
|
||||||
|
"publish_dialog_chip_click_label": "Klõpsi võrguaadressi",
|
||||||
|
"subscribe_dialog_subscribe_button_cancel": "Katkesta",
|
||||||
|
"publish_dialog_delay_label": "Viivitus",
|
||||||
|
"account_basics_password_title": "Salasõna",
|
||||||
|
"account_upgrade_dialog_button_cancel": "Katkesta",
|
||||||
|
"notifications_example": "Näide",
|
||||||
|
"account_usage_title": "Kasutus",
|
||||||
|
"account_basics_title": "Kasutajakonto",
|
||||||
|
"prefs_reservations_table_topic_header": "Teema",
|
||||||
|
"account_delete_dialog_button_cancel": "Katkesta",
|
||||||
|
"account_delete_dialog_label": "Salasõna",
|
||||||
|
"publish_dialog_message_label": "Sõnum",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_call": "Kõne",
|
||||||
|
"prefs_users_dialog_password_label": "Salasõna",
|
||||||
|
"subscribe_dialog_subscribe_button_subscribe": "Telli",
|
||||||
|
"publish_dialog_priority_label": "Prioriteet",
|
||||||
|
"subscribe_dialog_login_button_login": "Logi sisse",
|
||||||
|
"subscribe_dialog_error_user_anonymous": "anonüümne",
|
||||||
|
"prefs_appearance_theme_title": "Kujundus",
|
||||||
|
"publish_dialog_button_cancel": "Katkesta",
|
||||||
|
"account_usage_unlimited": "Piiramatu",
|
||||||
|
"prefs_notifications_delete_after_never": "Mitte kunagi",
|
||||||
|
"account_upgrade_dialog_interval_monthly": "Iga kuu",
|
||||||
|
"account_upgrade_dialog_tier_price_per_month": "kuu",
|
||||||
|
"prefs_notifications_web_push_disabled": "Pole kasutusel",
|
||||||
|
"prefs_appearance_title": "Välimus",
|
||||||
|
"prefs_appearance_language_title": "Keel",
|
||||||
|
"prefs_reservations_dialog_topic_label": "Teema",
|
||||||
|
"publish_dialog_priority_min": "Väikseim tähtsus",
|
||||||
|
"notifications_actions_failed_notification": "Ebaõnnestunud toiming",
|
||||||
|
"publish_dialog_title_label": "Pealkiri",
|
||||||
|
"publish_dialog_tags_label": "Sildid",
|
||||||
|
"publish_dialog_email_label": "E-post",
|
||||||
|
"display_name_dialog_placeholder": "Kuvatav nimi",
|
||||||
|
"publish_dialog_title_no_topic": "Avalda teavitus",
|
||||||
|
"publish_dialog_progress_uploading": "Laadin üles…",
|
||||||
|
"publish_dialog_message_published": "Teavitus on saadetud",
|
||||||
|
"publish_dialog_emoji_picker_show": "Vali emoji",
|
||||||
|
"publish_dialog_priority_low": "Vähetähtis",
|
||||||
|
"publish_dialog_priority_default": "Vaikimisi tähtsus",
|
||||||
|
"publish_dialog_priority_high": "Oluline",
|
||||||
|
"publish_dialog_priority_max": "Väga oluline",
|
||||||
|
"publish_dialog_base_url_label": "Teenuse võrguaadress",
|
||||||
|
"publish_dialog_topic_label": "Teema nimi",
|
||||||
|
"publish_dialog_topic_reset": "Lähtesta teema",
|
||||||
|
"publish_dialog_click_label": "Klõpsi võrguaadressi",
|
||||||
|
"publish_dialog_call_label": "Telefonikõne",
|
||||||
|
"publish_dialog_button_send": "Saada",
|
||||||
|
"publish_dialog_attach_label": "Manuse võrguaadress",
|
||||||
|
"publish_dialog_filename_placeholder": "Manuse failinimi",
|
||||||
|
"publish_dialog_other_features": "Lisavõimalused:",
|
||||||
|
"publish_dialog_chip_call_label": "Telefonikõne",
|
||||||
|
"publish_dialog_chip_delay_label": "Viivita saatmisega",
|
||||||
|
"publish_dialog_chip_topic_label": "Muuda teemat",
|
||||||
|
"publish_dialog_button_cancel_sending": "Katkesta saatmine",
|
||||||
|
"account_basics_username_title": "Kasutajanimi",
|
||||||
|
"account_basics_phone_numbers_dialog_channel_sms": "Tekstisõnum",
|
||||||
|
"account_basics_tier_admin": "Peakasutaja",
|
||||||
|
"account_basics_tier_basic": "Baasteenus",
|
||||||
|
"account_basics_tier_free": "Tasuta",
|
||||||
|
"account_basics_tier_interval_monthly": "kord kuus",
|
||||||
|
"account_basics_tier_interval_yearly": "kord aastas",
|
||||||
|
"account_basics_tier_change_button": "Muuda",
|
||||||
|
"account_upgrade_dialog_interval_yearly": "Kord aastas",
|
||||||
|
"account_upgrade_dialog_tier_selected_label": "Valitud",
|
||||||
|
"account_upgrade_dialog_tier_current_label": "Praegune",
|
||||||
|
"account_tokens_dialog_button_cancel": "Katkesta",
|
||||||
|
"prefs_notifications_title": "Teavitused",
|
||||||
|
"prefs_users_table_user_header": "Kasutaja",
|
||||||
|
"prefs_reservations_dialog_access_label": "Ligipääs",
|
||||||
|
"priority_min": "min",
|
||||||
|
"priority_low": "madal",
|
||||||
|
"priority_default": "vaikimisi",
|
||||||
|
"priority_high": "kõrge",
|
||||||
|
"priority_max": "kõrgeim",
|
||||||
|
"alert_notification_ios_install_required_description": "Teavituste lubamiseks iOS-is klõpsi „Jaga“ ikooni ja vali „Lisa avaekraanile“",
|
||||||
|
"notifications_none_for_topic_title": "Sul pole selles teemas veel ühtegi teavitust.",
|
||||||
|
"notifications_none_for_topic_description": "Selles teemas teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile.",
|
||||||
|
"publish_dialog_base_url_placeholder": "Teenuse võrguaadress, nt. https://toresait.com",
|
||||||
|
"notifications_loading": "Laadin teavitusi…",
|
||||||
|
"publish_dialog_title_topic": "Avalda teemas {{topic}}",
|
||||||
|
"publish_dialog_progress_uploading_detail": "Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …",
|
||||||
|
"publish_dialog_topic_placeholder": "Teema nimi, nt. kati_teavitused",
|
||||||
|
"publish_dialog_title_placeholder": "Teavituse pealkiri, nt. Andmeruumi teavitus",
|
||||||
|
"publish_dialog_message_placeholder": "Siia sisesta sõnum",
|
||||||
|
"notifications_none_for_any_title": "Sa pole veel saanud ühtegi teavitust.",
|
||||||
|
"publish_dialog_chip_attach_file_label": "Lisa kohalik fail",
|
||||||
|
"publish_dialog_chip_attach_url_label": "Lisa fail võrguaadressilt",
|
||||||
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Kinnitatud telefoninumbreid ei leidu",
|
||||||
|
"publish_dialog_chip_email_label": "Edasta e-posti aadressile",
|
||||||
|
"subscribe_dialog_subscribe_base_url_label": "Teenuse võrguaadress",
|
||||||
|
"subscribe_dialog_subscribe_button_generate_topic_name": "Loo nimi",
|
||||||
|
"publish_dialog_checkbox_markdown": "Kasuta Markdown-vormingut",
|
||||||
|
"subscribe_dialog_login_title": "Vajalik on sisselogimine",
|
||||||
|
"subscribe_dialog_login_username_label": "Kasutajanimi, nt. kadri",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_sms": "Saada SMS",
|
||||||
|
"account_basics_username_description": "Hei, see oled sina ❤",
|
||||||
|
"account_basics_username_admin_tooltip": "Sina oled peakasutaja",
|
||||||
|
"account_basics_phone_numbers_dialog_verify_button_call": "Helista mulle",
|
||||||
|
"account_basics_phone_numbers_dialog_code_label": "Kinnituskood",
|
||||||
|
"account_basics_phone_numbers_dialog_code_placeholder": "nt. 123456",
|
||||||
|
"account_basics_phone_numbers_dialog_check_verification_button": "Korda koodi",
|
||||||
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} sõnum päevas",
|
||||||
|
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} sõnumit päevas",
|
||||||
|
"account_upgrade_dialog_button_redirect_signup": "Liitu kohe",
|
||||||
|
"notifications_actions_http_request_title": "Tee päring HTTP {{method}}-meetodiga võrguaadressile {{url}}",
|
||||||
|
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} e-kirja päevas",
|
||||||
|
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-kiri päevas",
|
||||||
|
"alert_not_supported_context_description": "Teavitused võivad kasutada vaid HTTPS-ühendust. See on <mdnLink>Teavituste API</mdnLink> piirang.",
|
||||||
|
"publish_dialog_tags_placeholder": "Komadega eraldatud siltide loend, nt. hoiatus, srv1-varundus",
|
||||||
|
"display_name_dialog_title": "Muuda kuvatavat nime",
|
||||||
|
"display_name_dialog_description": "Lisa teemale alternatiivne nimi, mida kuvatakse tellimuste loendis. See on näiteks abiks keerukate nimedega teemade tuvastamiseks.",
|
||||||
|
"reserve_dialog_checkbox_label": "Reserveeri teema ja seadista ligipääs",
|
||||||
|
"publish_dialog_attachment_limits_file_reached": "ületab failisuuruse piiri: {{fileSizeLimit}}",
|
||||||
|
"publish_dialog_attachment_limits_quota_reached": "ületab kvooti, jäänud on {{remainingBytes}}",
|
||||||
|
"publish_dialog_attachment_limits_file_and_quota_reached": "ületab failisuuruse ülempiiri ({{fileSizeLimit}}) ja kvooti, jäänud on {{remainingBytes}}",
|
||||||
|
"publish_dialog_click_placeholder": "Teavituse klõpsimisel avatav võrguaadress",
|
||||||
|
"publish_dialog_click_reset": "Eemalda klikatav võrguaadress",
|
||||||
|
"publish_dialog_email_placeholder": "Aadress, kuhu teavitus edastatakse, nt. kadri@torefirma.com",
|
||||||
|
"publish_dialog_email_reset": "Eemalda edastamiseks kasutatav e-posti aadress",
|
||||||
|
"publish_dialog_call_item": "Helista telefoninumbrile {{number}}",
|
||||||
|
"publish_dialog_call_reset": "Eemalda helistamine",
|
||||||
|
"publish_dialog_attach_placeholder": "Lisa fail võrguaadressilt, nt. https://f-droid.org/F-Droid.apk",
|
||||||
|
"publish_dialog_attach_reset": "Eemalda manuse lisamisel kasutatav võrguaadress",
|
||||||
|
"publish_dialog_delay_reset": "Eemalda viivitus teavituse edastamisel",
|
||||||
|
"account_basics_password_description": "Muuda oma kasutajakonto salasõna",
|
||||||
|
"account_basics_password_dialog_title": "Salasõna muutmine",
|
||||||
|
"account_basics_password_dialog_current_password_label": "Senine salasõna",
|
||||||
|
"account_basics_password_dialog_button_submit": "Muuda salasõna",
|
||||||
|
"account_basics_password_dialog_current_password_incorrect": "Salasõna pole korrektne",
|
||||||
|
"account_basics_phone_numbers_title": "Telefoninumbrid",
|
||||||
|
"account_basics_phone_numbers_description": "Kõneteavituste jaoks",
|
||||||
|
"account_basics_tier_title": "Kasutajakonto tüüp",
|
||||||
|
"account_basics_tier_description": "Sinu kasutajakonto õigused",
|
||||||
|
"account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt",
|
||||||
|
"prefs_appearance_theme_system": "Süsteemi kujundus",
|
||||||
|
"prefs_appearance_theme_dark": "Tume kujundus",
|
||||||
|
"prefs_appearance_theme_light": "Hele kujundus",
|
||||||
|
"prefs_reservations_title": "Reserveeritud teemad",
|
||||||
|
"prefs_users_table": "Kasutajate loend",
|
||||||
|
"prefs_users_add_button": "Lisa kasutaja",
|
||||||
|
"prefs_users_edit_button": "Muuda kasutajat",
|
||||||
|
"prefs_users_delete_button": "Kustuta kasutaja",
|
||||||
|
"prefs_users_table_cannot_delete_or_edit": "Sisselogitud kasutajat ei saa kustutada ega muuta",
|
||||||
|
"prefs_users_table_base_url_header": "Teenuse võrguaadress",
|
||||||
|
"prefs_users_dialog_title_add": "Lisa kasutaja",
|
||||||
|
"prefs_users_dialog_title_edit": "Muuda kasutajat",
|
||||||
|
"prefs_users_dialog_base_url_label": "Teenuse võrguaadress, nt. https://ntfy.sh",
|
||||||
|
"prefs_users_dialog_username_label": "Kasutajanimi, nt. kadri",
|
||||||
|
"prefs_notifications_delete_after_three_hours": "Kolme tunni möödumisel",
|
||||||
|
"prefs_notifications_delete_after_three_hours_description": "Teavitused kustutatakse automaatselt kolme tunni möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_day_description": "Teavitused kustutatakse automaatselt ühe päeva möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_week_description": "Teavitused kustutatakse automaatselt ühe nädala möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_month_description": "Teavitused kustutatakse automaatselt ühe kuu möödumisel",
|
||||||
|
"prefs_notifications_delete_after_never_description": "Mitte kunagi ei kustutata teavitusi automaatselt",
|
||||||
|
"prefs_notifications_delete_after_title": "Kustuta teavitused",
|
||||||
|
"publish_dialog_delay_placeholder": "Viivitus teavituse edastamisel, nt. {{unixTimestamp}}, {{relativeTime}} või „{{naturalLanguage}}“ (vaid inglise keeles)",
|
||||||
|
"account_basics_password_dialog_new_password_label": "Uus salasõna",
|
||||||
|
"account_basics_password_dialog_confirm_password_label": "Korda salasõna",
|
||||||
|
"account_basics_phone_numbers_dialog_description": "Kõneteavituse kasutamiseks pead lisama ja kinnitama vähemalt ühe telefoninumbri. Kinnitamist saad teha SMS-i või kõne abil.",
|
||||||
|
"account_basics_phone_numbers_dialog_number_placeholder": "nt. +37256123456",
|
||||||
|
"account_basics_phone_numbers_no_phone_numbers_yet": "Telefoninumbreid veel pole",
|
||||||
|
"account_basics_phone_numbers_copied_to_clipboard": "Telefoninumber on kopeeritud lõikelauale",
|
||||||
|
"account_basics_phone_numbers_dialog_title": "Lisa telefoninumber",
|
||||||
|
"account_basics_phone_numbers_dialog_number_label": "Telefoninumber",
|
||||||
|
"prefs_notifications_delete_after_one_week": "Ühe nädala möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_day": "Ühe päeva möödumisel",
|
||||||
|
"prefs_notifications_delete_after_one_month": "Ühe kuu möödumisel"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,5 +228,19 @@
|
|||||||
"account_basics_password_dialog_new_password_label": "Parola nouă",
|
"account_basics_password_dialog_new_password_label": "Parola nouă",
|
||||||
"account_basics_password_title": "Parolă",
|
"account_basics_password_title": "Parolă",
|
||||||
"account_basics_tier_description": "Nivelul de putere al contului",
|
"account_basics_tier_description": "Nivelul de putere al contului",
|
||||||
"account_basics_tier_free": "Gratuit"
|
"account_basics_tier_free": "Gratuit",
|
||||||
|
"account_delete_description": "Șterge definitiv contul tău",
|
||||||
|
"account_usage_messages_title": "Mesaje publicate",
|
||||||
|
"account_basics_tier_manage_billing_button": "Gestionare facturare",
|
||||||
|
"account_usage_emails_title": "Emailuri trimise",
|
||||||
|
"account_usage_calls_title": "Apeluri telefonice efectuate",
|
||||||
|
"account_usage_calls_none": "Nu se pot efectua apeluri telefonice cu acest cont",
|
||||||
|
"account_usage_reservations_title": "Subiecte rezervate",
|
||||||
|
"account_usage_cannot_create_portal_session": "Nu s-a putut deschide portalul de facturare",
|
||||||
|
"account_delete_title": "Șterge contul",
|
||||||
|
"account_usage_attachment_storage_description": "{{filesize}} per fișier, șters după {{expiry}}",
|
||||||
|
"account_usage_attachment_storage_title": "Stocare atașamente",
|
||||||
|
"account_usage_basis_ip_description": "Statistica și limitele de utilizare pentru acest cont se bazează pe adresa ta IP, așadar pot fi partajate cu alți utilizatori. Limitele afișate mai sus sunt aproximative, bazate pe limitele de viteză existente.",
|
||||||
|
"account_usage_reservations_none": "Nu există subiecte rezervate pentru acest cont",
|
||||||
|
"account_basics_tier_canceled_subscription": "Abonamentul tău a fost anulat și va fi retrogradat la un cont gratuit în data de {{date}}."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
"signup_form_toggle_password_visibility": "Hiện mật khẩu",
|
"signup_form_toggle_password_visibility": "Hiện mật khẩu",
|
||||||
"login_form_button_submit": "Đăng nhập",
|
"login_form_button_submit": "Đăng nhập",
|
||||||
"common_copy_to_clipboard": "Lưu vào clipboard",
|
"common_copy_to_clipboard": "Lưu vào clipboard",
|
||||||
"signup_form_username": "Tên user",
|
"signup_form_username": "Tên đăng nhập",
|
||||||
"signup_already_have_account": "Đã có tài khoản? Đăng nhập!",
|
"signup_already_have_account": "Đã có tài khoản? Đăng nhập!",
|
||||||
"signup_disabled": "Đăng kí bị đóng",
|
"signup_disabled": "Đăng kí bị khoá",
|
||||||
"signup_error_username_taken": "Tên {{username}} đã được sử dụng",
|
"signup_error_username_taken": "Tên đăng nhập {{username}} đã được sử dụng",
|
||||||
"signup_error_creation_limit_reached": "Đã đạt giới hạn tạo tài khoản",
|
"signup_error_creation_limit_reached": "Đã đạt giới hạn tạo tài khoản",
|
||||||
"login_title": "Đăng nhập vào tài khoản ntfy",
|
"login_title": "Đăng nhập vào tài khoản ntfy",
|
||||||
"login_link_signup": "Đăng kí",
|
"login_link_signup": "Đăng kí",
|
||||||
@@ -27,5 +27,57 @@
|
|||||||
"action_bar_unsubscribe": "Hủy đăng kí",
|
"action_bar_unsubscribe": "Hủy đăng kí",
|
||||||
"action_bar_unmute_notifications": "Bật thông báo",
|
"action_bar_unmute_notifications": "Bật thông báo",
|
||||||
"action_bar_toggle_mute": "Bật/tắt thông báo",
|
"action_bar_toggle_mute": "Bật/tắt thông báo",
|
||||||
"action_bar_mute_notifications": "Tắt thông báo"
|
"action_bar_mute_notifications": "Tắt thông báo",
|
||||||
|
"common_save": "Lưu",
|
||||||
|
"common_cancel": "Hủy",
|
||||||
|
"nav_button_all_notifications": "Tất cả thông báo",
|
||||||
|
"nav_button_connecting": "đang kết nối",
|
||||||
|
"nav_upgrade_banner_label": "Nâng cấp tài khoản ntfy Pro",
|
||||||
|
"alert_not_supported_title": "Thông báo không được hỗ trợ",
|
||||||
|
"alert_not_supported_description": "Thông báo không được hỗ trợ trên trình duyệt của bạn",
|
||||||
|
"notifications_list": "Danh sách thông báo",
|
||||||
|
"notifications_list_item": "Thông báo",
|
||||||
|
"notifications_mark_read": "Đánh dấu đã đọc",
|
||||||
|
"notifications_delete": "Xoá",
|
||||||
|
"notifications_attachment_copy_url_title": "Sao chép URL đính kèm vào clipboard",
|
||||||
|
"notifications_attachment_copy_url_button": "Sao chép URL",
|
||||||
|
"notifications_attachment_open_title": "Truy cập {{url}}",
|
||||||
|
"notifications_click_copy_url_button": "Sao chép liên kết",
|
||||||
|
"notifications_click_open_button": "Mở liên kết",
|
||||||
|
"notifications_actions_not_supported": "Không được hỗ trợ trên nên tảng web",
|
||||||
|
"notifications_actions_http_request_title": "Gởi HTTP {{method}} tới {{url}}",
|
||||||
|
"action_bar_profile_settings": "Cài đặt",
|
||||||
|
"message_bar_type_message": "Gõ nội dung tại đây",
|
||||||
|
"nav_button_account": "Tài khoản",
|
||||||
|
"nav_button_settings": "Cài đặt",
|
||||||
|
"nav_button_documentation": "Tài liệu",
|
||||||
|
"alert_notification_permission_required_title": "Thông báo đã bị khoá",
|
||||||
|
"alert_notification_permission_required_button": "Cấp quyền ngay",
|
||||||
|
"alert_notification_permission_denied_title": "Thông báo đã bị chặn",
|
||||||
|
"alert_notification_ios_install_required_title": "Yêu cầu cài đặt iOS",
|
||||||
|
"alert_notification_ios_install_required_description": "Nhấn vào biểu tượng Chia sẻ và Thêm vào màn hình chính để kích hoạt thông báo trên iOS",
|
||||||
|
"alert_notification_permission_required_description": "Cấp quyền để trình duyệt hiển thị thông báo trên màn hình",
|
||||||
|
"alert_notification_permission_denied_description": "Hãy kích hoạt lại trên trình duyệt của bạn",
|
||||||
|
"notifications_copied_to_clipboard": "Đã lưu vào clipboard",
|
||||||
|
"notifications_attachment_file_video": "tập tin video",
|
||||||
|
"notifications_attachment_file_audio": "tập tin âm thanh",
|
||||||
|
"notifications_actions_failed_notification": "Thực thi thất bại",
|
||||||
|
"notifications_new_indicator": "Thông báo mới",
|
||||||
|
"notifications_click_copy_url_title": "Sao liên kết URL vào clipboard",
|
||||||
|
"notifications_actions_open_url_title": "Truy cập {{url}}",
|
||||||
|
"notifications_priority_x": "Độ ưu tiên {{priority}}",
|
||||||
|
"notifications_attachment_link_expired": "liên kết tải đã hết hạn",
|
||||||
|
"notifications_attachment_file_image": "tập tin hình ảnh",
|
||||||
|
"notifications_tags": "Thẻ",
|
||||||
|
"notifications_attachment_file_document": "tập tin khác",
|
||||||
|
"action_bar_sign_in": "Đăng nhập",
|
||||||
|
"notifications_attachment_image": "Hình ảnh đính kèm",
|
||||||
|
"action_bar_sign_up": "Đăng ký",
|
||||||
|
"action_bar_profile_title": "Hồ sơ",
|
||||||
|
"action_bar_toggle_action_menu": "Mở/Đóng bảng điều khiển",
|
||||||
|
"action_bar_profile_logout": "Đăng xuất",
|
||||||
|
"notifications_attachment_file_app": "tập tin Android",
|
||||||
|
"notifications_attachment_link_expires": "liên kết đã hết hạn {{date}}",
|
||||||
|
"alert_not_supported_context_description": "Thông báo chỉ được hỗ trợ qua giao thức HTTPS. Đây là hạn chế của <mdnLink>API thông báo</mdnLink>.",
|
||||||
|
"notifications_attachment_open_button": "Mở đính kèm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,8 +576,10 @@ const Language = () => {
|
|||||||
<MenuItem value="zh_Hans">简体中文</MenuItem>
|
<MenuItem value="zh_Hans">简体中文</MenuItem>
|
||||||
<MenuItem value="da">Dansk</MenuItem>
|
<MenuItem value="da">Dansk</MenuItem>
|
||||||
<MenuItem value="de">Deutsch</MenuItem>
|
<MenuItem value="de">Deutsch</MenuItem>
|
||||||
|
<MenuItem value="et">Eesti</MenuItem>
|
||||||
<MenuItem value="es">Español</MenuItem>
|
<MenuItem value="es">Español</MenuItem>
|
||||||
<MenuItem value="fr">Français</MenuItem>
|
<MenuItem value="fr">Français</MenuItem>
|
||||||
|
<MenuItem value="gl">Galego</MenuItem>
|
||||||
<MenuItem value="it">Italiano</MenuItem>
|
<MenuItem value="it">Italiano</MenuItem>
|
||||||
<MenuItem value="hu">Magyar</MenuItem>
|
<MenuItem value="hu">Magyar</MenuItem>
|
||||||
<MenuItem value="ko">한국어</MenuItem>
|
<MenuItem value="ko">한국어</MenuItem>
|
||||||
@@ -589,6 +591,8 @@ const Language = () => {
|
|||||||
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
|
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
|
||||||
<MenuItem value="pl">Polski</MenuItem>
|
<MenuItem value="pl">Polski</MenuItem>
|
||||||
<MenuItem value="ru">Русский</MenuItem>
|
<MenuItem value="ru">Русский</MenuItem>
|
||||||
|
<MenuItem value="ro">Română</MenuItem>
|
||||||
|
<MenuItem value="sk">Slovenčina</MenuItem>
|
||||||
<MenuItem value="fi">Suomi</MenuItem>
|
<MenuItem value="fi">Suomi</MenuItem>
|
||||||
<MenuItem value="sv">Svenska</MenuItem>
|
<MenuItem value="sv">Svenska</MenuItem>
|
||||||
<MenuItem value="tr">Türkçe</MenuItem>
|
<MenuItem value="tr">Türkçe</MenuItem>
|
||||||
|
|||||||