Merge branch 'enable-subscriber-rate-limiting' into matrix-507-reject
This commit is contained in:
		
						commit
						3eeeac2c13
					
				
					 28 changed files with 637 additions and 80 deletions
				
			
		
							
								
								
									
										26
									
								
								.github/ISSUE_TEMPLATE/1_bug_report.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.github/ISSUE_TEMPLATE/1_bug_report.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | ||||||
|  | --- | ||||||
|  | name: 🐛 Bug Report | ||||||
|  | about: Report any errors and problems | ||||||
|  | title: '' | ||||||
|  | labels: '🪲 bug' | ||||||
|  | assignees: '' | ||||||
|  | 
 | ||||||
|  | --- | ||||||
|  | 
 | ||||||
|  | :lady_beetle: **Describe the bug** | ||||||
|  | <!-- A clear and concise description of the problem. --> | ||||||
|  | 
 | ||||||
|  | :computer: **Components impacted** | ||||||
|  | <!-- ntfy server, Android app, iOS app, web app  --> | ||||||
|  | 
 | ||||||
|  | :bulb: **Screenshots and/or logs** | ||||||
|  | <!--  | ||||||
|  | If applicable, add screenshots or share logs help explain your problem. | ||||||
|  | To get logs from the ... | ||||||
|  | - ntfy server: Enable "log-level: trace" in your server.yml file | ||||||
|  | - Android app: Go to "Settings" -> "Record logs", then eventually "Copy/upload logs" | ||||||
|  | - web app: Press "F12" and find the "Console" window  | ||||||
|  | --> | ||||||
|  | 
 | ||||||
|  | :crystal_ball: **Additional context** | ||||||
|  | <!-- Add any other context about the problem here. --> | ||||||
							
								
								
									
										26
									
								
								.github/ISSUE_TEMPLATE/2_enhancement_request.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.github/ISSUE_TEMPLATE/2_enhancement_request.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | ||||||
|  | --- | ||||||
|  | name: 💡 Feature/Enhancement Request | ||||||
|  | about: Got a great idea? Let us know! | ||||||
|  | title: '' | ||||||
|  | labels: 'enhancement' | ||||||
|  | assignees: '' | ||||||
|  | 
 | ||||||
|  | --- | ||||||
|  | 
 | ||||||
|  | <!-- | ||||||
|  | 
 | ||||||
|  | Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer | ||||||
|  | sooner, and there are more people there to help! | ||||||
|  | 
 | ||||||
|  | - Discord: https://discord.gg/cT7ECsZj9w | ||||||
|  | - Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org | ||||||
|  | 
 | ||||||
|  | --> | ||||||
|  | 
 | ||||||
|  | :bulb: **Idea** | ||||||
|  | <!-- Share your thoughts; try to be detailed if you can --> | ||||||
|  | 
 | ||||||
|  | :computer: **Target components** | ||||||
|  | <!-- Where should this feature/enhancement be added? --> | ||||||
|  | <!-- e.g. ntfy server, Android app, iOS app, web app --> | ||||||
|  | 
 | ||||||
							
								
								
									
										21
									
								
								.github/ISSUE_TEMPLATE/3_tech_support.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.github/ISSUE_TEMPLATE/3_tech_support.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | --- | ||||||
|  | name: 🆘 I need help with ... | ||||||
|  | about: Installing ntfy, configuring the app, etc. | ||||||
|  | title: '' | ||||||
|  | labels: 'tech-support' | ||||||
|  | assignees: '' | ||||||
|  | 
 | ||||||
|  | --- | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | <!-- | ||||||
|  | 
 | ||||||
|  | STOP!  | ||||||
|  | 
 | ||||||
|  | This is not the right place to ask for help. Consider asking on Discord/Matrix instead.  | ||||||
|  | You'll usually get an answer sooner, and there are more people there to help! | ||||||
|  | 
 | ||||||
|  | - Discord: https://discord.gg/cT7ECsZj9w | ||||||
|  | - Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org | ||||||
|  | 
 | ||||||
|  | --> | ||||||
							
								
								
									
										21
									
								
								.github/ISSUE_TEMPLATE/4_question.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.github/ISSUE_TEMPLATE/4_question.md
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | --- | ||||||
|  | name: ❓ Question | ||||||
|  | about: Ask a question about ntfy | ||||||
|  | title: '' | ||||||
|  | labels: 'question' | ||||||
|  | assignees: '' | ||||||
|  | 
 | ||||||
|  | --- | ||||||
|  | 
 | ||||||
|  | <!-- | ||||||
|  | 
 | ||||||
|  | Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer | ||||||
|  | sooner, and there are more people there to help! | ||||||
|  | 
 | ||||||
|  | - Discord: https://discord.gg/cT7ECsZj9w | ||||||
|  | - Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org | ||||||
|  | 
 | ||||||
|  | --> | ||||||
|  | 
 | ||||||
|  | :question: **Question** | ||||||
|  | <!-- Go ahead and ask your question here :) --> | ||||||
							
								
								
									
										10
									
								
								Dockerfile
									
										
									
									
									
								
							
							
						
						
									
										10
									
								
								Dockerfile
									
										
									
									
									
								
							|  | @ -1,5 +1,13 @@ | ||||||
| FROM alpine | FROM alpine | ||||||
| MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com> | 
 | ||||||
|  | LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" | ||||||
|  | LABEL org.opencontainers.image.url="https://ntfy.sh/" | ||||||
|  | LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" | ||||||
|  | LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" | ||||||
|  | LABEL org.opencontainers.image.vendor="Philipp C. Heckel" | ||||||
|  | LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" | ||||||
|  | LABEL org.opencontainers.image.title="ntfy" | ||||||
|  | LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" | ||||||
| 
 | 
 | ||||||
| COPY ntfy /usr/bin | COPY ntfy /usr/bin | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -13,9 +13,14 @@ | ||||||
| [](https://ntfy.statuspage.io/) | [](https://ntfy.statuspage.io/) | ||||||
| [](https://gitpod.io/#https://github.com/binwiederhier/ntfy) | [](https://gitpod.io/#https://github.com/binwiederhier/ntfy) | ||||||
| 
 | 
 | ||||||
| **ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, **without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do so since ntfy is open source. | **ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)  | ||||||
|  | notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer,  | ||||||
|  | **without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do  | ||||||
|  | so since ntfy is open source. | ||||||
| 
 | 
 | ||||||
| You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android) available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). | You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android) | ||||||
|  | available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), | ||||||
|  | as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347). | ||||||
| 
 | 
 | ||||||
| <p> | <p> | ||||||
|   <img src="web/public/static/img/screenshot-curl.png" height="180"> |   <img src="web/public/static/img/screenshot-curl.png" height="180"> | ||||||
|  |  | ||||||
							
								
								
									
										10
									
								
								SECURITY.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								SECURITY.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,10 @@ | ||||||
|  | # Security Policy | ||||||
|  | 
 | ||||||
|  | ## Supported Versions | ||||||
|  | 
 | ||||||
|  | As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date. | ||||||
|  | 
 | ||||||
|  | ## Reporting a Vulnerability | ||||||
|  | 
 | ||||||
|  | Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w), | ||||||
|  | or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`). | ||||||
|  | @ -171,7 +171,7 @@ func execPublish(c *cli.Context) error { | ||||||
| 			fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) | 			fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20)) | ||||||
| 		} | 		} | ||||||
| 		options = append(options, client.WithBasicAuth(user, pass)) | 		options = append(options, client.WithBasicAuth(user, pass)) | ||||||
| 	} else if conf.DefaultUser != "" && conf.DefaultPassword != nil { | 	} else if token == "" && conf.DefaultUser != "" && conf.DefaultPassword != nil { | ||||||
| 		options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) | 		options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)) | ||||||
| 	} | 	} | ||||||
| 	if pid > 0 { | 	if pid > 0 { | ||||||
|  |  | ||||||
|  | @ -81,6 +81,7 @@ var flagsServe = append( | ||||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), | 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), | ||||||
| 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), | 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), | ||||||
| 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), | 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), | ||||||
|  | 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"enable_rate_visitor"}, EnvVars: []string{"NTFY_ENABLE_RATE_VISITOR"}, Value: false, Usage: "enables subscriber-based rate limiting for UnifiedPush topics"}), | ||||||
| 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), | 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), | ||||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), | ||||||
| 	altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), | 	altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), | ||||||
|  | @ -149,6 +150,7 @@ func execServe(c *cli.Context) error { | ||||||
| 	smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") | 	smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") | ||||||
| 	totalTopicLimit := c.Int("global-topic-limit") | 	totalTopicLimit := c.Int("global-topic-limit") | ||||||
| 	visitorSubscriptionLimit := c.Int("visitor-subscription-limit") | 	visitorSubscriptionLimit := c.Int("visitor-subscription-limit") | ||||||
|  | 	visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") | ||||||
| 	visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") | 	visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") | ||||||
| 	visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") | 	visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") | ||||||
| 	visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") | 	visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") | ||||||
|  | @ -177,8 +179,8 @@ func execServe(c *cli.Context) error { | ||||||
| 		return errors.New("if set, certificate file must exist") | 		return errors.New("if set, certificate file must exist") | ||||||
| 	} else if listenHTTPS != "" && (keyFile == "" || certFile == "") { | 	} else if listenHTTPS != "" && (keyFile == "" || certFile == "") { | ||||||
| 		return errors.New("if listen-https is set, both key-file and cert-file must be set") | 		return errors.New("if listen-https is set, both key-file and cert-file must be set") | ||||||
| 	} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") { | 	} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { | ||||||
| 		return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set") | 		return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") | ||||||
| 	} else if smtpServerListen != "" && smtpServerDomain == "" { | 	} else if smtpServerListen != "" && smtpServerDomain == "" { | ||||||
| 		return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") | 		return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") | ||||||
| 	} else if attachmentCacheDir != "" && baseURL == "" { | 	} else if attachmentCacheDir != "" && baseURL == "" { | ||||||
|  | @ -304,6 +306,7 @@ func execServe(c *cli.Context) error { | ||||||
| 	conf.VisitorMessageDailyLimit = visitorMessageDailyLimit | 	conf.VisitorMessageDailyLimit = visitorMessageDailyLimit | ||||||
| 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst | 	conf.VisitorEmailLimitBurst = visitorEmailLimitBurst | ||||||
| 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish | 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish | ||||||
|  | 	conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting | ||||||
| 	conf.BehindProxy = behindProxy | 	conf.BehindProxy = behindProxy | ||||||
| 	conf.StripeSecretKey = stripeSecretKey | 	conf.StripeSecretKey = stripeSecretKey | ||||||
| 	conf.StripeWebhookKey = stripeWebhookKey | 	conf.StripeWebhookKey = stripeWebhookKey | ||||||
|  |  | ||||||
|  | @ -932,6 +932,25 @@ If this ever happens, there will be a log message that looks something like this | ||||||
| WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor | WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | ### Subscriber-based rate limiting | ||||||
|  | By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment | ||||||
|  | size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits | ||||||
|  | of a topic's subscriber, instead of the limits of the publisher.** | ||||||
|  | 
 | ||||||
|  | If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed | ||||||
|  | to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume | ||||||
|  | publishers (e.g. Matrix/Mastodon servers) are allowed to send. | ||||||
|  | 
 | ||||||
|  | Once enabled, a client may send a `Rate-Topics: <topic1>,<topic2>,...` header when subscribing to topics via | ||||||
|  | HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits | ||||||
|  | to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic. | ||||||
|  | 
 | ||||||
|  | UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage` | ||||||
|  | response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's  | ||||||
|  | `visitor-message-daily-limit`. | ||||||
|  | 
 | ||||||
|  | To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`. | ||||||
|  | 
 | ||||||
| ## Tuning for scale | ## Tuning for scale | ||||||
| If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, | If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, | ||||||
| if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. | if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. | ||||||
|  | @ -1191,6 +1210,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | ||||||
| | `visitor-request-limit-replenish`          | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH`          | *duration*                                          | 5s                | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled                                                                                                                      | | | `visitor-request-limit-replenish`          | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH`          | *duration*                                          | 5s                | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled                                                                                                                      | | ||||||
| | `visitor-request-limit-exempt-hosts`       | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS`       | *comma-separated host/IP list*                      | -                 | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting                                                                                                                                                | | | `visitor-request-limit-exempt-hosts`       | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS`       | *comma-separated host/IP list*                      | -                 | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting                                                                                                                                                | | ||||||
| | `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30                | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 | | | `visitor-subscription-limit`               | `NTFY_VISITOR_SUBSCRIPTION_LIMIT`               | *number*                                            | 30                | Rate limiting: Number of subscriptions per visitor (IP address)                                                                                                                                                                 | | ||||||
|  | | `visitor-subscriber-rate-limiting`         | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING`         | *bool*                                              | `false`           | Rate limiting: Enables subscriber-based rate limiting                                                                                                                                                                           | | ||||||
| | `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app`, `home` or `disable`                          | `app`             | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable)                                                                                                                                  | | | `web-root`                                 | `NTFY_WEB_ROOT`                                 | `app`, `home` or `disable`                          | `app`             | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable)                                                                                                                                  | | ||||||
| | `enable-signup`                            | `NTFY_ENABLE_SIGNUP`                            | *boolean* (`true` or `false`)                       | `false`           | Allows users to sign up via the web app, or API                                                                                                                                                                                 | | | `enable-signup`                            | `NTFY_ENABLE_SIGNUP`                            | *boolean* (`true` or `false`)                       | `false`           | Allows users to sign up via the web app, or API                                                                                                                                                                                 | | ||||||
| | `enable-login`                             | `NTFY_ENABLE_LOGIN`                             | *boolean* (`true` or `false`)                       | `false`           | Allows users to log in via the web app, or API                                                                                                                                                                                  | | | `enable-login`                             | `NTFY_ENABLE_LOGIN`                             | *boolean* (`true` or `false`)                       | `false`           | Allows users to log in via the web app, or API                                                                                                                                                                                  | | ||||||
|  |  | ||||||
|  | @ -26,37 +26,37 @@ deb/rpm packages. | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_x86_64.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_x86_64.tar.gz | ||||||
|     tar zxvf ntfy_2.1.0_linux_x86_64.tar.gz |     tar zxvf ntfy_2.1.1_linux_x86_64.tar.gz | ||||||
|     sudo cp -a ntfy_2.1.0_linux_x86_64/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_2.1.1_linux_x86_64/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_x86_64/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_x86_64/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv6" | === "armv6" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.tar.gz | ||||||
|     tar zxvf ntfy_2.1.0_linux_armv6.tar.gz |     tar zxvf ntfy_2.1.1_linux_armv6.tar.gz | ||||||
|     sudo cp -a ntfy_2.1.0_linux_armv6/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_2.1.1_linux_armv6/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv6/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv6/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.tar.gz | ||||||
|     tar zxvf ntfy_2.1.0_linux_armv7.tar.gz |     tar zxvf ntfy_2.1.1_linux_armv7.tar.gz | ||||||
|     sudo cp -a ntfy_2.1.0_linux_armv7/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_2.1.1_linux_armv7/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv7/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv7/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.tar.gz |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.tar.gz | ||||||
|     tar zxvf ntfy_2.1.0_linux_arm64.tar.gz |     tar zxvf ntfy_2.1.1_linux_arm64.tar.gz | ||||||
|     sudo cp -a ntfy_2.1.0_linux_arm64/ntfy /usr/bin/ntfy |     sudo cp -a ntfy_2.1.1_linux_arm64/ntfy /usr/bin/ntfy | ||||||
|     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_arm64/{client,server}/*.yml /etc/ntfy |     sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_arm64/{client,server}/*.yml /etc/ntfy | ||||||
|     sudo ntfy serve |     sudo ntfy serve | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
|  | @ -106,7 +106,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_amd64.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -114,7 +114,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "armv6" | === "armv6" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -122,7 +122,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -130,7 +130,7 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.deb |     wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.deb | ||||||
|     sudo dpkg -i ntfy_*.deb |     sudo dpkg -i ntfy_*.deb | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|  | @ -140,28 +140,28 @@ Manually installing the .deb file: | ||||||
| 
 | 
 | ||||||
| === "x86_64/amd64" | === "x86_64/amd64" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_amd64.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.rpm | ||||||
|     sudo systemctl enable ntfy  |     sudo systemctl enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv6" | === "armv6" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.rpm | ||||||
|     sudo systemctl enable ntfy |     sudo systemctl enable ntfy | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "armv7/armhf" | === "armv7/armhf" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.rpm | ||||||
|     sudo systemctl enable ntfy  |     sudo systemctl enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
| 
 | 
 | ||||||
| === "arm64" | === "arm64" | ||||||
|     ```bash |     ```bash | ||||||
|     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.rpm |     sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.rpm | ||||||
|     sudo systemctl enable ntfy  |     sudo systemctl enable ntfy  | ||||||
|     sudo systemctl start ntfy |     sudo systemctl start ntfy | ||||||
|     ``` |     ``` | ||||||
|  | @ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos. | ||||||
| 
 | 
 | ||||||
| ## macOS | ## macOS | ||||||
| The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.  | The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.  | ||||||
| To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_macOS_all.tar.gz),  | To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_macOS_all.tar.gz),  | ||||||
| extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).  | extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).  | ||||||
| 
 | 
 | ||||||
| If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at  | If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at  | ||||||
| `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). | `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). | ||||||
| 
 | 
 | ||||||
| ```bash | ```bash | ||||||
| curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_macOS_all.tar.gz > ntfy_2.1.0_macOS_all.tar.gz | curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_macOS_all.tar.gz > ntfy_2.1.1_macOS_all.tar.gz | ||||||
| tar zxvf ntfy_2.1.0_macOS_all.tar.gz | tar zxvf ntfy_2.1.1_macOS_all.tar.gz | ||||||
| sudo cp -a ntfy_2.1.0_macOS_all/ntfy /usr/local/bin/ntfy | sudo cp -a ntfy_2.1.1_macOS_all/ntfy /usr/local/bin/ntfy | ||||||
| mkdir ~/Library/Application\ Support/ntfy  | mkdir ~/Library/Application\ Support/ntfy  | ||||||
| cp ntfy_2.1.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml | cp ntfy_2.1.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml | ||||||
| ntfy --help | ntfy --help | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
|  | @ -212,7 +212,7 @@ ntfy --help | ||||||
| 
 | 
 | ||||||
| ## Windows | ## Windows | ||||||
| The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. | The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. | ||||||
| To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_windows_x86_64.zip), | To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_windows_x86_64.zip), | ||||||
| extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.  | extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.  | ||||||
| 
 | 
 | ||||||
| The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). | The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). | ||||||
|  |  | ||||||
|  | @ -2,7 +2,21 @@ | ||||||
| Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) | Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) | ||||||
| and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). | and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). | ||||||
| 
 | 
 | ||||||
| ## ntfy server v2.1.1 (UNRELEASED) | ## ntfy server v2.2.0 (UNRELEASED) | ||||||
|  | 
 | ||||||
|  | **Features:** | ||||||
|  | 
 | ||||||
|  | * Support SMTP servers without auth ([#645](https://github.com/binwiederhier/ntfy/issues/645), thanks to [@Sharknoon](https://github.com/Sharknoon) for reporting) | ||||||
|  | 
 | ||||||
|  | **Bug fixes + maintenance:** | ||||||
|  | 
 | ||||||
|  | * Token auth doesn't work if default user credentials are defined in `client.yml` ([#650](https://github.com/binwiederhier/ntfy/issues/650), thanks to [@Xinayder](https://github.com/Xinayder)) | ||||||
|  | 
 | ||||||
|  | **Additional languages:** | ||||||
|  | 
 | ||||||
|  | * Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/)) | ||||||
|  | 
 | ||||||
|  | ## ntfy server v2.1.1 | ||||||
| Released March 1, 2023 | Released March 1, 2023 | ||||||
| 
 | 
 | ||||||
| This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,  | This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,  | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								go.mod
									
										
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
										
									
									
									
								
							|  | @ -19,7 +19,7 @@ require ( | ||||||
| 	golang.org/x/sync v0.1.0 | 	golang.org/x/sync v0.1.0 | ||||||
| 	golang.org/x/term v0.5.0 | 	golang.org/x/term v0.5.0 | ||||||
| 	golang.org/x/time v0.3.0 | 	golang.org/x/time v0.3.0 | ||||||
| 	google.golang.org/api v0.110.0 | 	google.golang.org/api v0.111.0 | ||||||
| 	gopkg.in/yaml.v2 v2.4.0 | 	gopkg.in/yaml.v2 v2.4.0 | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										4
									
								
								go.sum
									
										
									
									
									
								
							
							
						
						
									
										4
									
								
								go.sum
									
										
									
									
									
								
							|  | @ -165,8 +165,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn | ||||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||||
| golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= | ||||||
| golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= | ||||||
| google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU= | google.golang.org/api v0.111.0 h1:bwKi+z2BsdwYFRKrqwutM+axAlYLz83gt5pDSXCJT+0= | ||||||
| google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= | google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= | ||||||
| google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= | ||||||
| google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= | ||||||
| google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								log/event.go
									
										
									
									
									
								
							
							
						
						
									
										14
									
								
								log/event.go
									
										
									
									
									
								
							|  | @ -3,6 +3,7 @@ package log | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"heckel.io/ntfy/util" | ||||||
| 	"log" | 	"log" | ||||||
| 	"os" | 	"os" | ||||||
| 	"sort" | 	"sort" | ||||||
|  | @ -11,12 +12,11 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
| 	fieldTag        = "tag" | 	fieldTag       = "tag" | ||||||
| 	fieldError      = "error" | 	fieldError     = "error" | ||||||
| 	fieldTimeTaken  = "time_taken_ms" | 	fieldTimeTaken = "time_taken_ms" | ||||||
| 	fieldExitCode   = "exit_code" | 	fieldExitCode  = "exit_code" | ||||||
| 	tagStdLog       = "stdlog" | 	tagStdLog      = "stdlog" | ||||||
| 	timestampFormat = "2006-01-02T15:04:05.999Z07:00" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Event represents a single log event | // Event represents a single log event | ||||||
|  | @ -143,7 +143,7 @@ func (e *Event) Render(l Level, message string, v ...any) string { | ||||||
| 	} | 	} | ||||||
| 	e.Message = fmt.Sprintf(message, v...) | 	e.Message = fmt.Sprintf(message, v...) | ||||||
| 	e.Level = l | 	e.Level = l | ||||||
| 	e.Timestamp = e.time.Format(timestampFormat) | 	e.Timestamp = util.FormatTime(e.time) | ||||||
| 	if !appliedContexters { | 	if !appliedContexters { | ||||||
| 		e.applyContexters() | 		e.applyContexters() | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -124,6 +124,7 @@ type Config struct { | ||||||
| 	VisitorAuthFailureLimitBurst         int | 	VisitorAuthFailureLimitBurst         int | ||||||
| 	VisitorAuthFailureLimitReplenish     time.Duration | 	VisitorAuthFailureLimitReplenish     time.Duration | ||||||
| 	VisitorStatsResetTime                time.Time // Time of the day at which to reset visitor stats | 	VisitorStatsResetTime                time.Time // Time of the day at which to reset visitor stats | ||||||
|  | 	VisitorSubscriberRateLimiting        bool      // Enable subscriber-based rate limiting for UnifiedPush topics | ||||||
| 	BehindProxy                          bool | 	BehindProxy                          bool | ||||||
| 	StripeSecretKey                      string | 	StripeSecretKey                      string | ||||||
| 	StripeWebhookKey                     string | 	StripeWebhookKey                     string | ||||||
|  | @ -198,10 +199,12 @@ func NewConfig() *Config { | ||||||
| 		VisitorAuthFailureLimitBurst:         DefaultVisitorAuthFailureLimitBurst, | 		VisitorAuthFailureLimitBurst:         DefaultVisitorAuthFailureLimitBurst, | ||||||
| 		VisitorAuthFailureLimitReplenish:     DefaultVisitorAuthFailureLimitReplenish, | 		VisitorAuthFailureLimitReplenish:     DefaultVisitorAuthFailureLimitReplenish, | ||||||
| 		VisitorStatsResetTime:                DefaultVisitorStatsResetTime, | 		VisitorStatsResetTime:                DefaultVisitorStatsResetTime, | ||||||
|  | 		VisitorSubscriberRateLimiting:        false, | ||||||
| 		BehindProxy:                          false, | 		BehindProxy:                          false, | ||||||
| 		StripeSecretKey:                      "", | 		StripeSecretKey:                      "", | ||||||
| 		StripeWebhookKey:                     "", | 		StripeWebhookKey:                     "", | ||||||
| 		StripePriceCacheDuration:             DefaultStripePriceCacheDuration, | 		StripePriceCacheDuration:             DefaultStripePriceCacheDuration, | ||||||
|  | 		BillingContact:                       "", | ||||||
| 		EnableWeb:                            true, | 		EnableWeb:                            true, | ||||||
| 		EnableSignup:                         false, | 		EnableSignup:                         false, | ||||||
| 		EnableLogin:                          false, | 		EnableLogin:                          false, | ||||||
|  |  | ||||||
|  | @ -31,7 +31,7 @@ const ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var ( | var ( | ||||||
| 	normalErrorCodes       = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusInsufficientStorage} | 	normalErrorCodes       = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage} | ||||||
| 	rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge} | 	rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge} | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -597,7 +597,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes | ||||||
| 	if e != nil { | 	if e != nil { | ||||||
| 		return nil, e.With(t) | 		return nil, e.With(t) | ||||||
| 	} | 	} | ||||||
| 	if unifiedpush && t.RateVisitor() == nil { | 	if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil { | ||||||
| 		// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see | 		// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see | ||||||
| 		// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove | 		// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove | ||||||
| 		// the subscription as invalid if any 400-499 code (except 429/408) is returned. | 		// the subscription as invalid if any 400-499 code (except 429/408) is returned. | ||||||
|  | @ -1197,14 +1197,19 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu | ||||||
| // maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published | // maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published | ||||||
| // to that topic will be rate limited against the rate visitor instead of the publishing visitor. | // to that topic will be rate limited against the rate visitor instead of the publishing visitor. | ||||||
| // | // | ||||||
| // Setting the rate visitor is ony allowed if | // Setting the rate visitor is ony allowed if the `visitor-subscriber-rate-limiting` setting is enabled, AND | ||||||
| // - auth-file is not set (everything is open by default) | // - auth-file is not set (everything is open by default) | ||||||
| // - the topic is reserved, and v.user is the owner | // - or the topic is reserved, and v.user is the owner | ||||||
| // - the topic is not reserved, and v.user has write access | // - or the topic is not reserved, and v.user has write access | ||||||
| // | // | ||||||
| // Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition | // Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition | ||||||
| // until the Android app will send the "Rate-Topics" header. | // until the Android app will send the "Rate-Topics" header. | ||||||
| func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error { | func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error { | ||||||
|  | 	// Bail out if not enabled | ||||||
|  | 	if !s.config.VisitorSubscriberRateLimiting { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// Make a list of topics that we'll actually set the RateVisitor on | 	// Make a list of topics that we'll actually set the RateVisitor on | ||||||
| 	eligibleRateTopics := make([]*topic, 0) | 	eligibleRateTopics := make([]*topic, 0) | ||||||
| 	for _, t := range topics { | 	for _, t := range topics { | ||||||
|  |  | ||||||
|  | @ -117,18 +117,19 @@ | ||||||
| # attachment-expiry-duration: "3h" | # attachment-expiry-duration: "3h" | ||||||
| 
 | 
 | ||||||
| # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, | # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, | ||||||
| # messages will additionally be sent out as e-mail using an external SMTP server. As of today, only | # messages will additionally be sent out as e-mail using an external SMTP server. | ||||||
| # SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings | # | ||||||
| # below (visitor-email-limit-burst & visitor-email-limit-burst). | # As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported. | ||||||
|  | # Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst). | ||||||
| # | # | ||||||
| # - smtp-sender-addr is the hostname:port of the SMTP server | # - smtp-sender-addr is the hostname:port of the SMTP server | ||||||
| # - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user |  | ||||||
| # - smtp-sender-from is the e-mail address of the sender | # - smtp-sender-from is the e-mail address of the sender | ||||||
|  | # - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth) | ||||||
| # | # | ||||||
| # smtp-sender-addr: | # smtp-sender-addr: | ||||||
|  | # smtp-sender-from: | ||||||
| # smtp-sender-user: | # smtp-sender-user: | ||||||
| # smtp-sender-pass: | # smtp-sender-pass: | ||||||
| # smtp-sender-from: |  | ||||||
| 
 | 
 | ||||||
| # If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send | # If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send | ||||||
| # emails to a topic e-mail address to publish messages to a topic. | # emails to a topic e-mail address to publish messages to a topic. | ||||||
|  | @ -234,6 +235,21 @@ | ||||||
| # visitor-attachment-total-size-limit: "100M" | # visitor-attachment-total-size-limit: "100M" | ||||||
| # visitor-attachment-daily-bandwidth-limit: "500M" | # visitor-attachment-daily-bandwidth-limit: "500M" | ||||||
| 
 | 
 | ||||||
|  | # Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush) | ||||||
|  | # | ||||||
|  | # If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed | ||||||
|  | # to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume | ||||||
|  | # publishers (e.g. Matrix/Mastodon servers) are allowed to send. | ||||||
|  | # | ||||||
|  | # Once enabled, a client may send a "Rate-Topics: <topic1>,<topic2>,..." header when subscribing to topics via | ||||||
|  | # HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits | ||||||
|  | # to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic. | ||||||
|  | # | ||||||
|  | # UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if | ||||||
|  | # no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit". | ||||||
|  | # | ||||||
|  | # visitor-subscriber-rate-limiting: false | ||||||
|  | 
 | ||||||
| # Payments integration via Stripe | # Payments integration via Stripe | ||||||
| # | # | ||||||
| # - stripe-secret-key is the key used for the Stripe API communication. Setting this values | # - stripe-secret-key is the key used for the Stripe API communication. Setting this values | ||||||
|  |  | ||||||
|  | @ -657,6 +657,17 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { | ||||||
| 	m2 := toMessage(t, rr.Body.String()) | 	m2 := toMessage(t, rr.Body.String()) | ||||||
| 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) | 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) | ||||||
| 
 | 
 | ||||||
|  | 	// Pre-verify message count and file | ||||||
|  | 	ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) | ||||||
|  | 	require.Nil(t, err) | ||||||
|  | 	require.Equal(t, 1, len(ms)) | ||||||
|  | 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) | ||||||
|  | 
 | ||||||
|  | 	ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false) | ||||||
|  | 	require.Nil(t, err) | ||||||
|  | 	require.Equal(t, 1, len(ms)) | ||||||
|  | 	require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID)) | ||||||
|  | 
 | ||||||
| 	// Delete reservation | 	// Delete reservation | ||||||
| 	rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{ | 	rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{ | ||||||
| 		"X-Delete-Messages": "true", | 		"X-Delete-Messages": "true", | ||||||
|  | @ -672,9 +683,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| 	// Verify that messages and attachments were deleted | 	// Verify that messages and attachments were deleted | ||||||
| 	// This does not explicitly call the manager! | 	// This does not explicitly call the manager! | ||||||
| 	time.Sleep(time.Second) | 	waitFor(t, func() bool { | ||||||
|  | 		ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) | ||||||
|  | 		require.Nil(t, err) | ||||||
|  | 		return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) | ||||||
|  | 	}) | ||||||
| 
 | 
 | ||||||
| 	ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false) | 	ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false) | ||||||
| 	require.Nil(t, err) | 	require.Nil(t, err) | ||||||
| 	require.Equal(t, 0, len(ms)) | 	require.Equal(t, 0, len(ms)) | ||||||
| 	require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) | 	require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID)) | ||||||
|  | @ -712,13 +727,12 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) { | ||||||
| 	}) | 	}) | ||||||
| 	require.Equal(t, 200, rr.Code) | 	require.Equal(t, 200, rr.Code) | ||||||
| 
 | 
 | ||||||
| 	// Wait for stats queue writer | 	// Wait for stats queue writer, verify that message stats were persisted | ||||||
| 	time.Sleep(600 * time.Millisecond) | 	waitFor(t, func() bool { | ||||||
| 
 | 		u, err := s.userManager.User("phil") | ||||||
| 	// Verify that message stats were persisted | 		require.Nil(t, err) | ||||||
| 	u, err := s.userManager.User("phil") | 		return int64(1) == u.Stats.Messages | ||||||
| 	require.Nil(t, err) | 	}) | ||||||
| 	require.Equal(t, int64(1), u.Stats.Messages) |  | ||||||
| 
 | 
 | ||||||
| 	// Change tier, make a request (to reset limiters) | 	// Change tier, make a request (to reset limiters) | ||||||
| 	require.Nil(t, s.userManager.ChangeTier("phil", "pro")) | 	require.Nil(t, s.userManager.ChangeTier("phil", "pro")) | ||||||
|  | @ -736,10 +750,11 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) { | ||||||
| 	require.Equal(t, 200, rr.Code) | 	require.Equal(t, 200, rr.Code) | ||||||
| 
 | 
 | ||||||
| 	// Verify that message stats were persisted | 	// Verify that message stats were persisted | ||||||
| 	time.Sleep(600 * time.Millisecond) | 	waitFor(t, func() bool { | ||||||
| 	u, err = s.userManager.User("phil") | 		u, err := s.userManager.User("phil") | ||||||
| 	require.Nil(t, err) | 		require.Nil(t, err) | ||||||
| 	require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run! | 		return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run! | ||||||
|  | 	}) | ||||||
| 
 | 
 | ||||||
| 	// Stats keep counting | 	// Stats keep counting | ||||||
| 	rr = request(t, s, "GET", "/v1/account", "", map[string]string{ | 	rr = request(t, s, "GET", "/v1/account", "", map[string]string{ | ||||||
|  |  | ||||||
|  | @ -15,6 +15,7 @@ import ( | ||||||
| 	"net/netip" | 	"net/netip" | ||||||
| 	"os" | 	"os" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
|  | 	"runtime/debug" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync" | 	"sync" | ||||||
| 	"testing" | 	"testing" | ||||||
|  | @ -914,7 +915,15 @@ func TestServer_StatsResetter(t *testing.T) { | ||||||
| 	require.Equal(t, int64(2), account.Stats.Messages) | 	require.Equal(t, int64(2), account.Stats.Messages) | ||||||
| 
 | 
 | ||||||
| 	// Wait for stats resetter to run | 	// Wait for stats resetter to run | ||||||
| 	time.Sleep(2200 * time.Millisecond) | 	waitFor(t, func() bool { | ||||||
|  | 		response = request(t, s, "GET", "/v1/account", "", map[string]string{ | ||||||
|  | 			"Authorization": util.BasicAuth("phil", "phil"), | ||||||
|  | 		}) | ||||||
|  | 		require.Equal(t, 200, response.Code) | ||||||
|  | 		account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body)) | ||||||
|  | 		require.Nil(t, err) | ||||||
|  | 		return account.Stats.Messages == 0 | ||||||
|  | 	}) | ||||||
| 
 | 
 | ||||||
| 	// User stats show 0 messages now! | 	// User stats show 0 messages now! | ||||||
| 	response = request(t, s, "GET", "/v1/account", "", map[string]string{ | 	response = request(t, s, "GET", "/v1/account", "", map[string]string{ | ||||||
|  | @ -1283,7 +1292,9 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) { | func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) { | ||||||
| 	s := newTestServer(t, newTestConfig(t)) | 	c := newTestConfig(t) | ||||||
|  | 	c.VisitorSubscriberRateLimiting = true | ||||||
|  | 	s := newTestServer(t, c) | ||||||
| 	notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` | 	notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}` | ||||||
| 	response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) | 	response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil) | ||||||
| 	require.Equal(t, 507, response.Code) | 	require.Equal(t, 507, response.Code) | ||||||
|  | @ -1661,9 +1672,10 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) { | ||||||
| 	require.Equal(t, content, response.Body.String()) | 	require.Equal(t, content, response.Body.String()) | ||||||
| 
 | 
 | ||||||
| 	// Prune and makes sure it's gone | 	// Prune and makes sure it's gone | ||||||
| 	time.Sleep(time.Second) // Sigh ... | 	waitFor(t, func() bool { | ||||||
| 	s.execManager() | 		s.execManager() // May run many times | ||||||
| 	require.NoFileExists(t, file) | 		return !util.FileExists(file) | ||||||
|  | 	}) | ||||||
| 	response = request(t, s, "GET", path, "", nil) | 	response = request(t, s, "GET", path, "", nil) | ||||||
| 	require.Equal(t, 404, response.Code) | 	require.Equal(t, 404, response.Code) | ||||||
| } | } | ||||||
|  | @ -2020,6 +2032,7 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) { | ||||||
| func TestServer_SubscriberRateLimiting_Success(t *testing.T) { | func TestServer_SubscriberRateLimiting_Success(t *testing.T) { | ||||||
| 	c := newTestConfigWithAuthFile(t) | 	c := newTestConfigWithAuthFile(t) | ||||||
| 	c.VisitorRequestLimitBurst = 3 | 	c.VisitorRequestLimitBurst = 3 | ||||||
|  | 	c.VisitorSubscriberRateLimiting = true | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 
 | 
 | ||||||
| 	// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor | 	// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor | ||||||
|  | @ -2031,6 +2044,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) { | ||||||
| 	}, subscriber1Fn) | 	}, subscriber1Fn) | ||||||
| 	require.Equal(t, 200, rr.Code) | 	require.Equal(t, 200, rr.Code) | ||||||
| 	require.Equal(t, "", rr.Body.String()) | 	require.Equal(t, "", rr.Body.String()) | ||||||
|  | 	require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String()) | ||||||
| 
 | 
 | ||||||
| 	// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name) | 	// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name) | ||||||
| 	subscriber2Fn := func(r *http.Request) { | 	subscriber2Fn := func(r *http.Request) { | ||||||
|  | @ -2039,6 +2053,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) { | ||||||
| 	rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn) | 	rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn) | ||||||
| 	require.Equal(t, 200, rr.Code) | 	require.Equal(t, 200, rr.Code) | ||||||
| 	require.Equal(t, "", rr.Body.String()) | 	require.Equal(t, "", rr.Body.String()) | ||||||
|  | 	require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String()) | ||||||
| 
 | 
 | ||||||
| 	// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the | 	// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the | ||||||
| 	// GET request before is also counted towards the request limiter. | 	// GET request before is also counted towards the request limiter. | ||||||
|  | @ -2070,9 +2085,47 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) { | ||||||
| 	require.Equal(t, 429, rr.Code) | 	require.Equal(t, 429, rr.Code) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) { | ||||||
|  | 	c := newTestConfigWithAuthFile(t) | ||||||
|  | 	c.VisitorRequestLimitBurst = 3 | ||||||
|  | 	c.VisitorSubscriberRateLimiting = false | ||||||
|  | 	s := newTestServer(t, c) | ||||||
|  | 
 | ||||||
|  | 	// Subscriber rate limiting is disabled! | ||||||
|  | 
 | ||||||
|  | 	// Registering visitor 1.2.3.4 to topic has no effect | ||||||
|  | 	rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{ | ||||||
|  | 		"Rate-Topics": "subscriber1topic", | ||||||
|  | 	}, func(r *http.Request) { | ||||||
|  | 		r.RemoteAddr = "1.2.3.4" | ||||||
|  | 	}) | ||||||
|  | 	require.Equal(t, 200, rr.Code) | ||||||
|  | 	require.Equal(t, "", rr.Body.String()) | ||||||
|  | 	require.Nil(t, s.topics["subscriber1topic"].rateVisitor) | ||||||
|  | 
 | ||||||
|  | 	// Registering visitor 8.7.7.1 to topic has no effect | ||||||
|  | 	rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) { | ||||||
|  | 		r.RemoteAddr = "8.7.7.1" | ||||||
|  | 	}) | ||||||
|  | 	require.Equal(t, 200, rr.Code) | ||||||
|  | 	require.Equal(t, "", rr.Body.String()) | ||||||
|  | 	require.Nil(t, s.topics["up012345678912"].rateVisitor) | ||||||
|  | 
 | ||||||
|  | 	// Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9 | ||||||
|  | 	for i := 0; i < 3; i++ { | ||||||
|  | 		rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil) | ||||||
|  | 		require.Equal(t, 200, rr.Code) | ||||||
|  | 	} | ||||||
|  | 	rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil) | ||||||
|  | 	require.Equal(t, 429, rr.Code) | ||||||
|  | 	rr = request(t, s, "PUT", "/up012345678912", "some message", nil) | ||||||
|  | 	require.Equal(t, 429, rr.Code) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { | func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { | ||||||
| 	c := newTestConfigWithAuthFile(t) | 	c := newTestConfigWithAuthFile(t) | ||||||
| 	c.VisitorRequestLimitBurst = 3 | 	c.VisitorRequestLimitBurst = 3 | ||||||
|  | 	c.VisitorSubscriberRateLimiting = true | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 
 | 
 | ||||||
| 	// "Register" 5 different UnifiedPush visitors | 	// "Register" 5 different UnifiedPush visitors | ||||||
|  | @ -2096,6 +2149,7 @@ func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) { | ||||||
| func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { | func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { | ||||||
| 	c := newTestConfig(t) | 	c := newTestConfig(t) | ||||||
| 	c.VisitorRequestLimitBurst = 3 | 	c.VisitorRequestLimitBurst = 3 | ||||||
|  | 	c.VisitorSubscriberRateLimiting = true | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 
 | 
 | ||||||
| 	// "Register" 5 different UnifiedPush visitors | 	// "Register" 5 different UnifiedPush visitors | ||||||
|  | @ -2123,6 +2177,7 @@ func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) { | ||||||
| func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { | func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { | ||||||
| 	c := newTestConfig(t) | 	c := newTestConfig(t) | ||||||
| 	c.VisitorRequestLimitBurst = 3 | 	c.VisitorRequestLimitBurst = 3 | ||||||
|  | 	c.VisitorSubscriberRateLimiting = true | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 
 | 
 | ||||||
| 	// "Register" rate visitor | 	// "Register" rate visitor | ||||||
|  | @ -2158,6 +2213,7 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) { | ||||||
| func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) { | func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) { | ||||||
| 	c := newTestConfigWithAuthFile(t) | 	c := newTestConfigWithAuthFile(t) | ||||||
| 	c.AuthDefault = user.PermissionDenyAll | 	c.AuthDefault = user.PermissionDenyAll | ||||||
|  | 	c.VisitorSubscriberRateLimiting = true | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 
 | 
 | ||||||
| 	// Create some ACLs | 	// Create some ACLs | ||||||
|  | @ -2205,6 +2261,7 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) { | ||||||
| func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) { | func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) { | ||||||
| 	c := newTestConfigWithAuthFile(t) | 	c := newTestConfigWithAuthFile(t) | ||||||
| 	c.AuthDefault = user.PermissionReadWrite | 	c.AuthDefault = user.PermissionReadWrite | ||||||
|  | 	c.VisitorSubscriberRateLimiting = true | ||||||
| 	s := newTestServer(t, c) | 	s := newTestServer(t, c) | ||||||
| 
 | 
 | ||||||
| 	// Create some ACLs | 	// Create some ACLs | ||||||
|  | @ -2311,3 +2368,18 @@ func readAll(t *testing.T, rc io.ReadCloser) string { | ||||||
| 	} | 	} | ||||||
| 	return string(b) | 	return string(b) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func waitFor(t *testing.T, f func() bool) { | ||||||
|  | 	waitForWithMaxWait(t, 5*time.Second, f) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) { | ||||||
|  | 	start := time.Now() | ||||||
|  | 	for time.Since(start) < maxWait { | ||||||
|  | 		if f() { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		time.Sleep(100 * time.Millisecond) | ||||||
|  | 	} | ||||||
|  | 	t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack())) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -36,7 +36,10 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error { | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 		auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) | 		var auth smtp.Auth | ||||||
|  | 		if s.config.SMTPSenderUser != "" { | ||||||
|  | 			auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) | ||||||
|  | 		} | ||||||
| 		ev := logvm(v, m). | 		ev := logvm(v, m). | ||||||
| 			Tag(tagEmail). | 			Tag(tagEmail). | ||||||
| 			Fields(log.Context{ | 			Fields(log.Context{ | ||||||
|  |  | ||||||
|  | @ -143,6 +143,7 @@ func (v *visitor) contextNoLock() log.Context { | ||||||
| 	fields := log.Context{ | 	fields := log.Context{ | ||||||
| 		"visitor_id":                     visitorID(v.ip, v.user), | 		"visitor_id":                     visitorID(v.ip, v.user), | ||||||
| 		"visitor_ip":                     v.ip.String(), | 		"visitor_ip":                     v.ip.String(), | ||||||
|  | 		"visitor_seen":                   util.FormatTime(v.seen), | ||||||
| 		"visitor_messages":               info.Stats.Messages, | 		"visitor_messages":               info.Stats.Messages, | ||||||
| 		"visitor_messages_limit":         info.Limits.MessageLimit, | 		"visitor_messages_limit":         info.Limits.MessageLimit, | ||||||
| 		"visitor_messages_remaining":     info.Stats.MessagesRemaining, | 		"visitor_messages_remaining":     info.Stats.MessagesRemaining, | ||||||
|  |  | ||||||
|  | @ -14,6 +14,15 @@ var ( | ||||||
| 	durationStrRegex  = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`) | 	durationStrRegex  = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`) | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | const ( | ||||||
|  | 	timestampFormat = "2006-01-02T15:04:05.999Z07:00" // Like RFC3339, but with milliseconds | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // FormatTime formats a time.Time in a RFC339-like format that includes milliseconds | ||||||
|  | func FormatTime(t time.Time) string { | ||||||
|  | 	return t.Format(timestampFormat) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence | // NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence | ||||||
| // of that time from the current time (in UTC). | // of that time from the current time (in UTC). | ||||||
| func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time { | func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time { | ||||||
|  |  | ||||||
|  | @ -39,7 +39,7 @@ | ||||||
|     "message_bar_type_message": "اكتب رسالة هنا", |     "message_bar_type_message": "اكتب رسالة هنا", | ||||||
|     "alert_not_supported_title": "الإشعارات غير مدعومة", |     "alert_not_supported_title": "الإشعارات غير مدعومة", | ||||||
|     "alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.", |     "alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.", | ||||||
|     "message_bar_error_publishing": "خطأ أثناء نشر الإشعار", |     "message_bar_error_publishing": "خطأ خلال نشر الإشعار", | ||||||
|     "notifications_delete": "حذف", |     "notifications_delete": "حذف", | ||||||
|     "notifications_copied_to_clipboard": "تم نسخه إلى الحافظة", |     "notifications_copied_to_clipboard": "تم نسخه إلى الحافظة", | ||||||
|     "action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات", |     "action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات", | ||||||
|  | @ -277,5 +277,11 @@ | ||||||
|     "prefs_reservations_table_click_to_subscribe": "انقر للاشتراك", |     "prefs_reservations_table_click_to_subscribe": "انقر للاشتراك", | ||||||
|     "reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا", |     "reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا", | ||||||
|     "action_bar_reservation_delete": "إزالة الحجز", |     "action_bar_reservation_delete": "إزالة الحجز", | ||||||
|     "display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر." |     "display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.", | ||||||
|  |     "prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.", | ||||||
|  |     "notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على <websiteLink>موقع الويب</websiteLink> أو على <docsLink>الدليل</docsLink>.", | ||||||
|  |     "publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى <docsLink>الدليل</docsLink>.", | ||||||
|  |     "subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".", | ||||||
|  |     "prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها", | ||||||
|  |     "notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع." | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1 +1,225 @@ | ||||||
| {} | { | ||||||
|  |     "common_save": "Gem", | ||||||
|  |     "common_add": "Tilføj", | ||||||
|  |     "signup_title": "Opret en ntfy konto", | ||||||
|  |     "signup_form_username": "Brugernavn", | ||||||
|  |     "signup_form_password": "Kodeord", | ||||||
|  |     "signup_form_confirm_password": "Bekræft kodeord", | ||||||
|  |     "common_cancel": "Annuller", | ||||||
|  |     "action_bar_account": "Konto", | ||||||
|  |     "signup_error_username_taken": "Brugernavnet {{username}} er optaget", | ||||||
|  |     "login_form_button_submit": "Log ind", | ||||||
|  |     "action_bar_show_menu": "Vis menu", | ||||||
|  |     "action_bar_logo_alt": "ntfy logo", | ||||||
|  |     "action_bar_settings": "Indstillinger", | ||||||
|  |     "signup_form_button_submit": "Opret konto", | ||||||
|  |     "signup_form_toggle_password_visibility": "Skift synlighed af adgangskode", | ||||||
|  |     "signup_disabled": "Tilmelding er deaktiveret", | ||||||
|  |     "signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået", | ||||||
|  |     "login_title": "Log ind på din ntfy konto", | ||||||
|  |     "login_link_signup": "Opret konto", | ||||||
|  |     "login_disabled": "Login er deaktiveret", | ||||||
|  |     "action_bar_reservation_add": "Reserver emne", | ||||||
|  |     "action_bar_reservation_edit": "Rediger reservation", | ||||||
|  |     "action_bar_reservation_delete": "Fjern reservation", | ||||||
|  |     "action_bar_reservation_limit_reached": "Grænsen er nået", | ||||||
|  |     "action_bar_send_test_notification": "Send test notifikation", | ||||||
|  |     "action_bar_unsubscribe": "Afmeld", | ||||||
|  |     "action_bar_toggle_mute": "Slå lyden fra/til for notifikationer", | ||||||
|  |     "action_bar_change_display_name": "Skift visningsnavn", | ||||||
|  |     "action_bar_toggle_action_menu": "Åben/luk handlings menu", | ||||||
|  |     "action_bar_profile_title": "Profil", | ||||||
|  |     "action_bar_profile_settings": "Indstillinger", | ||||||
|  |     "action_bar_profile_logout": "Log ud", | ||||||
|  |     "action_bar_sign_in": "Log ind", | ||||||
|  |     "action_bar_sign_up": "Opret konto", | ||||||
|  |     "message_bar_type_message": "Skriv en besked her", | ||||||
|  |     "nav_button_settings": "Indstillinger", | ||||||
|  |     "message_bar_publish": "Offentliggør besked", | ||||||
|  |     "nav_topics_title": "Tilmeldte emner", | ||||||
|  |     "nav_button_all_notifications": "Alle notifikationer", | ||||||
|  |     "nav_button_connecting": "forbinder", | ||||||
|  |     "nav_upgrade_banner_label": "Opgrader til ntfy Pro", | ||||||
|  |     "alert_grant_title": "Notifikationer er deaktiveret", | ||||||
|  |     "alert_grant_description": "Giv din browser tilladelse til at vise skrivebordsnotifikationer.", | ||||||
|  |     "alert_not_supported_title": "Notifikationer understøttes ikke", | ||||||
|  |     "alert_not_supported_description": "Notifikationer understøttes ikke i din browser.", | ||||||
|  |     "alert_not_supported_context_description": "Notifikationer understøttes kun via HTTPS. Dette skyldes en begrænsning i <mdnLink>Notifications API</mdnLink>.", | ||||||
|  |     "nav_button_subscribe": "Abonner på emne", | ||||||
|  |     "notifications_list_item": "Notifikation", | ||||||
|  |     "notifications_delete": "Slet", | ||||||
|  |     "notifications_tags": "Tags", | ||||||
|  |     "notifications_list": "Notifikationsliste", | ||||||
|  |     "notifications_mark_read": "Marker som læst", | ||||||
|  |     "notifications_copied_to_clipboard": "Kopieret til udklipsholder", | ||||||
|  |     "notifications_priority_x": "Prioritet {{priority}}", | ||||||
|  |     "notifications_attachment_copy_url_title": "Kopier URL-adresse til vedhæftet fil til udklipsholder", | ||||||
|  |     "notifications_attachment_copy_url_button": "Kopier URL", | ||||||
|  |     "notifications_attachment_open_title": "Gå til {{url}}", | ||||||
|  |     "notifications_attachment_open_button": "Åben vedhæftning", | ||||||
|  |     "notifications_attachment_link_expires": "link udløber {{date}}", | ||||||
|  |     "notifications_attachment_link_expired": "download link er udløbet", | ||||||
|  |     "notifications_attachment_file_image": "billedfil", | ||||||
|  |     "notifications_attachment_file_app": "Android app fil", | ||||||
|  |     "notifications_attachment_file_document": "andet dokument", | ||||||
|  |     "notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen", | ||||||
|  |     "notifications_click_copy_url_button": "Kopier link", | ||||||
|  |     "notifications_example": "Eksempel", | ||||||
|  |     "notifications_click_open_button": "Åbn link", | ||||||
|  |     "notifications_actions_not_supported": "Handlingen understøttes ikke i webappen", | ||||||
|  |     "notifications_actions_http_request_title": "Send HTTP {{method}} til {{url}}", | ||||||
|  |     "notifications_none_for_topic_title": "Du har ikke modtaget nogen notifikationer om dette emne endnu.", | ||||||
|  |     "notifications_none_for_any_title": "Du har ikke modtaget nogen notifikationer.", | ||||||
|  |     "display_name_dialog_placeholder": "Vist navn", | ||||||
|  |     "publish_dialog_progress_uploading": "Uploader…", | ||||||
|  |     "display_name_dialog_title": "Skift visningsnavn", | ||||||
|  |     "publish_dialog_progress_uploading_detail": "Uploader {{loaded}}/{{total}} ({{percent}}%) …", | ||||||
|  |     "publish_dialog_emoji_picker_show": "Vælg emoji", | ||||||
|  |     "publish_dialog_priority_min": "Min. prioritet", | ||||||
|  |     "publish_dialog_priority_low": "Lav prioritet", | ||||||
|  |     "publish_dialog_priority_default": "Standardprioritet", | ||||||
|  |     "publish_dialog_priority_high": "Høj prioritet", | ||||||
|  |     "publish_dialog_title_label": "Titel", | ||||||
|  |     "publish_dialog_message_label": "Besked", | ||||||
|  |     "publish_dialog_tags_label": "Tags", | ||||||
|  |     "publish_dialog_priority_label": "Prioritet", | ||||||
|  |     "publish_dialog_message_placeholder": "Skriv en besked her", | ||||||
|  |     "publish_dialog_tags_placeholder": "Komma-separeret liste over tags, f.eks. warning, srv1-backup", | ||||||
|  |     "publish_dialog_click_label": "Klik på URL", | ||||||
|  |     "publish_dialog_email_reset": "Fjern videresendelse af e-mail", | ||||||
|  |     "publish_dialog_attach_placeholder": "Vedhæft fil via URL, f.eks. https://f-droid.org/F-Droid.apk", | ||||||
|  |     "publish_dialog_delay_label": "Forsinkelse", | ||||||
|  |     "publish_dialog_button_send": "Send", | ||||||
|  |     "subscribe_dialog_subscribe_button_subscribe": "Tilmeld", | ||||||
|  |     "subscribe_dialog_login_button_back": "Tilbage", | ||||||
|  |     "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil", | ||||||
|  |     "account_basics_title": "Konto", | ||||||
|  |     "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret", | ||||||
|  |     "account_basics_username_admin_tooltip": "Du er Admin", | ||||||
|  |     "account_basics_password_dialog_confirm_password_label": "Bekræft kodeord", | ||||||
|  |     "account_basics_password_dialog_current_password_incorrect": "Forkert kodeord", | ||||||
|  |     "account_usage_of_limit": "af {{limit}}", | ||||||
|  |     "account_basics_tier_basic": "Grundlæggende", | ||||||
|  |     "account_basics_tier_free": "Gratis", | ||||||
|  |     "account_basics_tier_admin_suffix_no_tier": "(intet niveau)", | ||||||
|  |     "account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} niveau)", | ||||||
|  |     "account_usage_messages_title": "Offentliggjorte meddelelser", | ||||||
|  |     "account_delete_dialog_button_submit": "Slet konto permanent", | ||||||
|  |     "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pr. fil", | ||||||
|  |     "account_upgrade_dialog_button_redirect_signup": "Tilmeld dig nu", | ||||||
|  |     "account_tokens_table_expires_header": "Udløber", | ||||||
|  |     "account_tokens_table_last_access_header": "Seneste adgang", | ||||||
|  |     "account_tokens_delete_dialog_title": "Slet adgangstoken", | ||||||
|  |     "prefs_notifications_sound_no_sound": "Ingen lyd", | ||||||
|  |     "prefs_notifications_min_priority_title": "Minimumsprioritet", | ||||||
|  |     "prefs_notifications_sound_play": "Afspil den valgte lyd", | ||||||
|  |     "prefs_notifications_min_priority_max_only": "Kun maks. prioritet", | ||||||
|  |     "prefs_notifications_delete_after_three_hours": "Efter tre timer", | ||||||
|  |     "prefs_users_add_button": "Tilføj bruger", | ||||||
|  |     "prefs_users_dialog_title_edit": "Rediger bruger", | ||||||
|  |     "prefs_reservations_title": "Reserverede emner", | ||||||
|  |     "prefs_reservations_add_button": "Tilføj reserveret emne", | ||||||
|  |     "prefs_reservations_table_access_header": "Adgang", | ||||||
|  |     "prefs_reservations_delete_button": "Nulstil emneadgang", | ||||||
|  |     "prefs_reservations_dialog_title_edit": "Rediger reserveret emne", | ||||||
|  |     "prefs_reservations_dialog_access_label": "Adgang", | ||||||
|  |     "prefs_reservations_dialog_title_delete": "Slet emnereservation", | ||||||
|  |     "priority_low": "lav", | ||||||
|  |     "priority_min": "min", | ||||||
|  |     "reservation_delete_dialog_submit_button": "Slet reservation", | ||||||
|  |     "priority_high": "høj", | ||||||
|  |     "priority_max": "maks", | ||||||
|  |     "error_boundary_stack_trace": "Strack trace", | ||||||
|  |     "error_boundary_button_copy_stack_trace": "Kopier stack trace", | ||||||
|  |     "signup_already_have_account": "Har du allerede en konto? Log ind!", | ||||||
|  |     "action_bar_clear_notifications": "Ryd alle notifikationer", | ||||||
|  |     "notifications_new_indicator": "Ny notifikation", | ||||||
|  |     "notifications_attachment_image": "Vedhæftet billede", | ||||||
|  |     "account_delete_dialog_label": "Kodeord", | ||||||
|  |     "error_boundary_unsupported_indexeddb_title": "Privat browsing understøttes ikke", | ||||||
|  |     "notifications_actions_open_url_title": "Gå til {{url}}", | ||||||
|  |     "notifications_attachment_file_audio": "lydfil", | ||||||
|  |     "publish_dialog_click_placeholder": "URL der åbnes, når der klikkes på notifikationen", | ||||||
|  |     "publish_dialog_email_placeholder": "Adresse, som meddelelsen skal videresendes til, f.eks. phil@example.com", | ||||||
|  |     "notifications_attachment_file_video": "videofil", | ||||||
|  |     "account_basics_tier_title": "Kontotype", | ||||||
|  |     "publish_dialog_filename_label": "Filnavn", | ||||||
|  |     "account_basics_tier_manage_billing_button": "Administrer fakturering", | ||||||
|  |     "account_usage_emails_title": "Afsendte e-mails", | ||||||
|  |     "account_usage_reservations_title": "Reserverede emner", | ||||||
|  |     "account_delete_title": "Slet konto", | ||||||
|  |     "nav_button_account": "Konto", | ||||||
|  |     "nav_button_documentation": "Dokumentation", | ||||||
|  |     "publish_dialog_priority_max": "Maks. prioritet", | ||||||
|  |     "account_upgrade_dialog_button_cancel_subscription": "Opsig abonnement", | ||||||
|  |     "account_upgrade_dialog_button_update_subscription": "Opdater abonnement", | ||||||
|  |     "publish_dialog_button_cancel": "Annuller", | ||||||
|  |     "publish_dialog_email_label": "Email", | ||||||
|  |     "account_tokens_title": "Adgangstokens", | ||||||
|  |     "account_tokens_table_never_expires": "Udløber aldrig", | ||||||
|  |     "prefs_notifications_sound_title": "Notifikationslyd", | ||||||
|  |     "account_tokens_dialog_button_update": "Opdater token", | ||||||
|  |     "account_tokens_dialog_button_create": "Opret token", | ||||||
|  |     "subscribe_dialog_subscribe_button_cancel": "Annuller", | ||||||
|  |     "prefs_users_table_user_header": "Bruger", | ||||||
|  |     "prefs_appearance_title": "Udseende", | ||||||
|  |     "subscribe_dialog_login_button_login": "Log ind", | ||||||
|  |     "subscribe_dialog_login_password_label": "Kodeord", | ||||||
|  |     "subscribe_dialog_error_user_anonymous": "anonym", | ||||||
|  |     "account_usage_title": "Anvendelse", | ||||||
|  |     "account_basics_username_title": "Brugernavn", | ||||||
|  |     "account_basics_tier_admin": "Admin", | ||||||
|  |     "account_basics_password_title": "Kodeord", | ||||||
|  |     "account_upgrade_dialog_tier_selected_label": "Valgt", | ||||||
|  |     "account_usage_unlimited": "Ubegrænset", | ||||||
|  |     "account_tokens_table_label_header": "Label", | ||||||
|  |     "account_tokens_dialog_button_cancel": "Annuller", | ||||||
|  |     "account_basics_tier_change_button": "Rediger", | ||||||
|  |     "account_delete_dialog_button_cancel": "Annuller", | ||||||
|  |     "account_upgrade_dialog_button_cancel": "Annuller", | ||||||
|  |     "account_tokens_table_token_header": "Token", | ||||||
|  |     "account_upgrade_dialog_tier_current_label": "Nuværende", | ||||||
|  |     "prefs_notifications_title": "Notifikationer", | ||||||
|  |     "prefs_notifications_delete_after_never": "Aldrig", | ||||||
|  |     "prefs_reservations_table_topic_header": "Emne", | ||||||
|  |     "prefs_users_dialog_password_label": "Kodeord", | ||||||
|  |     "prefs_appearance_language_title": "Sprog", | ||||||
|  |     "prefs_reservations_dialog_topic_label": "Emne", | ||||||
|  |     "priority_default": "standard", | ||||||
|  |     "publish_dialog_attached_file_remove": "Fjern vedhæftet fil", | ||||||
|  |     "prefs_users_table": "Bruger tabel", | ||||||
|  |     "prefs_users_edit_button": "Rediger bruger", | ||||||
|  |     "prefs_users_dialog_title_add": "Tilføj bruger", | ||||||
|  |     "prefs_users_delete_button": "Slet bruger", | ||||||
|  |     "account_tokens_table_copied_to_clipboard": "Adgangstoken kopieret", | ||||||
|  |     "prefs_notifications_min_priority_any": "Enhver prioritet", | ||||||
|  |     "prefs_notifications_delete_after_title": "Slet notifikationer", | ||||||
|  |     "publish_dialog_delay_reset": "Fjern forsinket levering", | ||||||
|  |     "prefs_users_title": "Administrer brugere", | ||||||
|  |     "account_basics_password_dialog_button_submit": "Skift kodeord", | ||||||
|  |     "prefs_reservations_dialog_title_add": "Reserver emne", | ||||||
|  |     "account_basics_password_dialog_current_password_label": "Nuværende kodeord", | ||||||
|  |     "account_basics_password_dialog_new_password_label": "Nyt kodeord", | ||||||
|  |     "notifications_loading": "Indlæser notifikationer…", | ||||||
|  |     "account_upgrade_dialog_tier_features_emails": "{{emails}} daglige e-mails", | ||||||
|  |     "account_tokens_table_create_token_button": "Opret adgangstoken", | ||||||
|  |     "account_tokens_dialog_title_delete": "Slet adgangstoken", | ||||||
|  |     "publish_dialog_chip_email_label": "Videresend til e-mail", | ||||||
|  |     "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} samlet lagerplads", | ||||||
|  |     "subscribe_dialog_subscribe_use_another_label": "Brug en anden server", | ||||||
|  |     "account_basics_tier_upgrade_button": "Opgrader til Pro", | ||||||
|  |     "account_upgrade_dialog_tier_features_messages": "{{messages}} daglige beskeder", | ||||||
|  |     "account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder", | ||||||
|  |     "prefs_reservations_edit_button": "Rediger emneadgang", | ||||||
|  |     "account_upgrade_dialog_title": "Skift kontoniveau", | ||||||
|  |     "account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserverede emner", | ||||||
|  |     "account_tokens_dialog_expires_never": "Token udløber aldrig", | ||||||
|  |     "account_tokens_table_current_session": "Nuværende browsersession", | ||||||
|  |     "account_tokens_dialog_title_edit": "Rediger adgangstoken", | ||||||
|  |     "account_tokens_dialog_title_create": "Opret adgangstoken", | ||||||
|  |     "prefs_notifications_delete_after_one_day": "Efter en dag", | ||||||
|  |     "account_tokens_delete_dialog_submit_button": "Slet token permanent", | ||||||
|  |     "prefs_notifications_delete_after_one_month": "Efter en måned", | ||||||
|  |     "prefs_notifications_delete_after_one_week": "Efter en uge", | ||||||
|  |     "prefs_users_dialog_username_label": "Brugernavn, f.eks. phil" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -187,5 +187,53 @@ | ||||||
|     "prefs_notifications_delete_after_never": "Nigdy", |     "prefs_notifications_delete_after_never": "Nigdy", | ||||||
|     "prefs_users_dialog_title_edit": "Edytuj użytkownika", |     "prefs_users_dialog_title_edit": "Edytuj użytkownika", | ||||||
|     "priority_min": "minimum", |     "priority_min": "minimum", | ||||||
|     "error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>." |     "error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>.", | ||||||
|  |     "signup_form_password": "Hasło", | ||||||
|  |     "signup_title": "Załóż konto ntfy", | ||||||
|  |     "signup_error_creation_limit_reached": "Przekroczono limit zakładania kont", | ||||||
|  |     "action_bar_reservation_limit_reached": "Limit wyczerpany", | ||||||
|  |     "display_name_dialog_title": "Zmień wyświetlaną nazwę", | ||||||
|  |     "display_name_dialog_description": "Ustaw alternatywną nazwę dla tematu wyświetlanego na liście subskrybcji. To ułatwia identyfikację tematów o skomplikowanych nazwach.", | ||||||
|  |     "account_basics_title": "Konto", | ||||||
|  |     "account_basics_password_dialog_title": "Zmień hasło", | ||||||
|  |     "signup_form_username": "Nawa użytkownika", | ||||||
|  |     "signup_form_confirm_password": "Powtórz hasło", | ||||||
|  |     "signup_form_button_submit": "Załóż konto", | ||||||
|  |     "signup_form_toggle_password_visibility": "Pokaż lub ukryj hasło", | ||||||
|  |     "signup_already_have_account": "Masz już konto? Zaloguj się!", | ||||||
|  |     "signup_disabled": "Zakładanie kont jest wyłączone", | ||||||
|  |     "signup_error_username_taken": "Nazwa użytkownika {{username}} jest już zajęta", | ||||||
|  |     "login_title": "Zaloguj się do swojego konta ntfy", | ||||||
|  |     "login_form_button_submit": "Zaloguj się", | ||||||
|  |     "login_link_signup": "Załóż konto", | ||||||
|  |     "login_disabled": "Logowanie jet wyłączone", | ||||||
|  |     "action_bar_account": "Konto", | ||||||
|  |     "action_bar_change_display_name": "Zmień wyświetlaną nazwę", | ||||||
|  |     "action_bar_reservation_add": "Zarezerwuj temat", | ||||||
|  |     "action_bar_reservation_edit": "Zmień rezerwację", | ||||||
|  |     "action_bar_reservation_delete": "Usuń rezerwację", | ||||||
|  |     "action_bar_profile_title": "Profil", | ||||||
|  |     "action_bar_profile_settings": "Ustawienia", | ||||||
|  |     "action_bar_profile_logout": "Wyloguj", | ||||||
|  |     "action_bar_sign_in": "Zaloguj", | ||||||
|  |     "action_bar_sign_up": "Załóż konto", | ||||||
|  |     "nav_button_account": "Konto", | ||||||
|  |     "display_name_dialog_placeholder": "Nazwa wyświetlana", | ||||||
|  |     "reserve_dialog_checkbox_label": "Zarezerwuj temat i skonfiguruj dostęp", | ||||||
|  |     "subscribe_dialog_subscribe_button_generate_topic_name": "Wygeneruj nazwę", | ||||||
|  |     "subscribe_dialog_error_topic_already_reserved": "Temat już jest zarezerwowany", | ||||||
|  |     "account_basics_username_title": "Nazwa użytkownika", | ||||||
|  |     "account_basics_username_description": "Hej, to Ty ❤", | ||||||
|  |     "account_basics_username_admin_tooltip": "Jesteś Administratorem", | ||||||
|  |     "account_basics_password_title": "Hasło", | ||||||
|  |     "account_basics_password_description": "Zmień hasło do konta", | ||||||
|  |     "account_basics_password_dialog_current_password_label": "Aktualne hasło", | ||||||
|  |     "account_basics_password_dialog_new_password_label": "Nowe hasło", | ||||||
|  |     "account_basics_password_dialog_confirm_password_label": "Powtórz hasło", | ||||||
|  |     "account_basics_password_dialog_button_submit": "Zmień hasło", | ||||||
|  |     "account_basics_password_dialog_current_password_incorrect": "Błędne hasło", | ||||||
|  |     "account_usage_title": "Użycie", | ||||||
|  |     "account_usage_of_limit": "z {{limit}}", | ||||||
|  |     "account_usage_unlimited": "Bez limitu", | ||||||
|  |     "account_usage_limits_reset_daily": "Limity są resetowane codziennie o północy (UTC)" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -461,6 +461,7 @@ const Language = () => { | ||||||
|                     <MenuItem value="bg">Български</MenuItem> |                     <MenuItem value="bg">Български</MenuItem> | ||||||
|                     <MenuItem value="cs">Čeština</MenuItem> |                     <MenuItem value="cs">Čeština</MenuItem> | ||||||
|                     <MenuItem value="zh_Hans">中文</MenuItem> |                     <MenuItem value="zh_Hans">中文</MenuItem> | ||||||
|  |                     <MenuItem value="da">Dansk</MenuItem> | ||||||
|                     <MenuItem value="de">Deutsch</MenuItem> |                     <MenuItem value="de">Deutsch</MenuItem> | ||||||
|                     <MenuItem value="es">Español</MenuItem> |                     <MenuItem value="es">Español</MenuItem> | ||||||
|                     <MenuItem value="fr">Français</MenuItem> |                     <MenuItem value="fr">Français</MenuItem> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue