Jelajahi Sumber

Merge branch 'main' into patch-1

binwiederhier 1 tahun lalu
induk
melakukan
4e1980c2cc

+ 8 - 14
.github/workflows/build.yaml

@@ -1,30 +1,24 @@
 name: build
-on: [push, pull_request]
+on: [ push, pull_request ]
 jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      -
-        name: Checkout code
+      - name: Checkout code
         uses: actions/checkout@v3
-      -
-        name: Install Go
+      - name: Install Go
         uses: actions/setup-go@v4
         with:
-          go-version: '1.21.x'
-      -
-        name: Install node
+          go-version: '1.22.x'
+      - name: Install node
         uses: actions/setup-node@v3
         with:
           node-version: '20'
           cache: 'npm'
           cache-dependency-path: './web/package-lock.json'
-      -
-        name: Install dependencies
+      - name: Install dependencies
         run: make build-deps-ubuntu
-      -
-        name: Build all the things
+      - name: Build all the things
         run: make build
-      -
-        name: Print build results and checksums
+      - name: Print build results and checksums
         run: make cli-build-results

+ 8 - 15
.github/workflows/release.yaml

@@ -7,35 +7,28 @@ jobs:
   release:
     runs-on: ubuntu-latest
     steps:
-      -
-        name: Checkout code
+      - name: Checkout code
         uses: actions/checkout@v3
-      -
-        name: Install Go
+      - name: Install Go
         uses: actions/setup-go@v4
         with:
-          go-version: '1.21.x'
-      -
-        name: Install node
+          go-version: '1.22.x'
+      - name: Install node
         uses: actions/setup-node@v3
         with:
           node-version: '20'
           cache: 'npm'
           cache-dependency-path: './web/package-lock.json'
-      -
-        name: Docker login
+      - name: Docker login
         uses: docker/login-action@v2
         with:
           username: ${{ github.repository_owner }}
           password: ${{ secrets.DOCKER_HUB_TOKEN }}
-      -
-        name: Install dependencies
+      - name: Install dependencies
         run: make build-deps-ubuntu
-      -
-        name: Build and publish
+      - name: Build and publish
         run: make release
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-      -
-        name: Print build results and checksums
+      - name: Print build results and checksums
         run: make cli-build-results

+ 11 - 20
.github/workflows/test.yaml

@@ -1,39 +1,30 @@
 name: test
-on: [push, pull_request]
+on: [ push, pull_request ]
 jobs:
   test:
     runs-on: ubuntu-latest
     steps:
-      -
-        name: Checkout code
+      - name: Checkout code
         uses: actions/checkout@v3
-      -
-        name: Install Go
+      - name: Install Go
         uses: actions/setup-go@v4
         with:
-          go-version: '1.21.x'
-      -
-        name: Install node
+          go-version: '1.22.x'
+      - name: Install node
         uses: actions/setup-node@v3
         with:
           node-version: '20'
           cache: 'npm'
           cache-dependency-path: './web/package-lock.json'
-      -
-        name: Install dependencies
+      - name: Install dependencies
         run: make build-deps-ubuntu
-      -
-        name: Build docs (required for tests)
+      - name: Build docs (required for tests)
         run: make docs
-      -
-        name: Build web app (required for tests)
+      - name: Build web app (required for tests)
         run: make web
-      -
-        name: Run tests, formatting, vetting and linting
+      - name: Run tests, formatting, vetting and linting
         run: make check
-      -
-        name: Run coverage
+      - name: Run coverage
         run: make coverage
-      -
-        name: Upload coverage to codecov.io
+      - name: Upload coverage to codecov.io
         run: make coverage-upload

+ 2 - 1
Dockerfile

@@ -9,7 +9,8 @@ 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"
 
-RUN apk add --no-cache tzdata
+RUN apk add --no-cache tzdata \
+ && adduser -D -u 1000 ntfy
 COPY ntfy /usr/bin
 
 EXPOSE 80/tcp

+ 1 - 0
Dockerfile-arm

@@ -12,6 +12,7 @@ LABEL org.opencontainers.image.description="Send push notifications to your phon
 # Alpine does not support adding "tzdata" on ARM anymore, see
 # https://github.com/binwiederhier/ntfy/issues/894
 
+RUN adduser -D -u 1000 ntfy
 COPY ntfy /usr/bin
 
 EXPOSE 80/tcp

+ 1 - 0
Dockerfile-build

@@ -53,6 +53,7 @@ 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"
 
+RUN adduser -D -u 1000 ntfy
 COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy
 
 EXPOSE 80/tcp

+ 103 - 61
cmd/serve.go

@@ -6,23 +6,22 @@ import (
 	"errors"
 	"fmt"
 	"github.com/stripe/stripe-go/v74"
+	"github.com/urfave/cli/v2"
+	"github.com/urfave/cli/v2/altsrc"
+	"heckel.io/ntfy/v2/log"
+	"heckel.io/ntfy/v2/server"
 	"heckel.io/ntfy/v2/user"
+	"heckel.io/ntfy/v2/util"
 	"io/fs"
 	"math"
 	"net"
 	"net/netip"
+	"net/url"
 	"os"
 	"os/signal"
 	"strings"
 	"syscall"
 	"time"
-
-	"heckel.io/ntfy/v2/log"
-
-	"github.com/urfave/cli/v2"
-	"github.com/urfave/cli/v2/altsrc"
-	"heckel.io/ntfy/v2/server"
-	"heckel.io/ntfy/v2/util"
 )
 
 func init() {
@@ -35,7 +34,7 @@ const (
 
 var flagsServe = append(
 	append([]cli.Flag{}, flagsDefault...),
-	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
+	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"},
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
@@ -45,19 +44,19 @@ var flagsServe = append(
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
-	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
-	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Value: util.FormatDuration(server.DefaultCacheBatchTimeout), Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
-	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
-	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
-	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
-	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
-	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}),
 	altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}),
 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
@@ -76,16 +75,18 @@ var flagsServe = append(
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
-	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
-	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
 	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
-	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
+	altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
 	altsrc.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"}),
@@ -126,7 +127,7 @@ func execServe(c *cli.Context) error {
 
 	// Read all the options
 	config := c.String("config")
-	baseURL := c.String("base-url")
+	baseURL := strings.TrimSuffix(c.String("base-url"), "/")
 	listenHTTP := c.String("listen-http")
 	listenHTTPS := c.String("listen-https")
 	listenUnix := c.String("listen-unix")
@@ -140,19 +141,19 @@ func execServe(c *cli.Context) error {
 	webPushEmailAddress := c.String("web-push-email-address")
 	webPushStartupQueries := c.String("web-push-startup-queries")
 	cacheFile := c.String("cache-file")
-	cacheDuration := c.Duration("cache-duration")
+	cacheDurationStr := c.String("cache-duration")
 	cacheStartupQueries := c.String("cache-startup-queries")
 	cacheBatchSize := c.Int("cache-batch-size")
-	cacheBatchTimeout := c.Duration("cache-batch-timeout")
+	cacheBatchTimeoutStr := c.String("cache-batch-timeout")
 	authFile := c.String("auth-file")
 	authStartupQueries := c.String("auth-startup-queries")
 	authDefaultAccess := c.String("auth-default-access")
 	attachmentCacheDir := c.String("attachment-cache-dir")
 	attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
 	attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
-	attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
-	keepaliveInterval := c.Duration("keepalive-interval")
-	managerInterval := c.Duration("manager-interval")
+	attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
+	keepaliveIntervalStr := c.String("keepalive-interval")
+	managerIntervalStr := c.String("manager-interval")
 	disallowedTopics := c.StringSlice("disallowed-topics")
 	webRoot := c.String("web-root")
 	enableSignup := c.Bool("enable-signup")
@@ -171,17 +172,19 @@ func execServe(c *cli.Context) error {
 	twilioAuthToken := c.String("twilio-auth-token")
 	twilioPhoneNumber := c.String("twilio-phone-number")
 	twilioVerifyService := c.String("twilio-verify-service")
+	messageSizeLimitStr := c.String("message-size-limit")
+	messageDelayLimitStr := c.String("message-delay-limit")
 	totalTopicLimit := c.Int("global-topic-limit")
 	visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
 	visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
 	visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
 	visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
 	visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
-	visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
+	visitorRequestLimitReplenishStr := c.String("visitor-request-limit-replenish")
 	visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
 	visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
-	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
+	visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
 	behindProxy := c.Bool("behind-proxy")
 	stripeSecretKey := c.String("stripe-secret-key")
 	stripeWebhookKey := c.String("stripe-webhook-key")
@@ -190,6 +193,64 @@ func execServe(c *cli.Context) error {
 	enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
 	profileListenHTTP := c.String("profile-listen-http")
 
+	// Convert durations
+	cacheDuration, err := util.ParseDuration(cacheDurationStr)
+	if err != nil {
+		return fmt.Errorf("invalid cache duration: %s", cacheDurationStr)
+	}
+	cacheBatchTimeout, err := util.ParseDuration(cacheBatchTimeoutStr)
+	if err != nil {
+		return fmt.Errorf("invalid cache batch timeout: %s", cacheBatchTimeoutStr)
+	}
+	attachmentExpiryDuration, err := util.ParseDuration(attachmentExpiryDurationStr)
+	if err != nil {
+		return fmt.Errorf("invalid attachment expiry duration: %s", attachmentExpiryDurationStr)
+	}
+	keepaliveInterval, err := util.ParseDuration(keepaliveIntervalStr)
+	if err != nil {
+		return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr)
+	}
+	managerInterval, err := util.ParseDuration(managerIntervalStr)
+	if err != nil {
+		return fmt.Errorf("invalid manager interval: %s", managerIntervalStr)
+	}
+	messageDelayLimit, err := util.ParseDuration(messageDelayLimitStr)
+	if err != nil {
+		return fmt.Errorf("invalid message delay limit: %s", messageDelayLimitStr)
+	}
+	visitorRequestLimitReplenish, err := util.ParseDuration(visitorRequestLimitReplenishStr)
+	if err != nil {
+		return fmt.Errorf("invalid visitor request limit replenish: %s", visitorRequestLimitReplenishStr)
+	}
+	visitorEmailLimitReplenish, err := util.ParseDuration(visitorEmailLimitReplenishStr)
+	if err != nil {
+		return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
+	}
+
+	// Convert sizes to bytes
+	messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
+	if err != nil {
+		return fmt.Errorf("invalid message size limit: %s", messageSizeLimitStr)
+	}
+	attachmentTotalSizeLimit, err := util.ParseSize(attachmentTotalSizeLimitStr)
+	if err != nil {
+		return fmt.Errorf("invalid attachment total size limit: %s", attachmentTotalSizeLimitStr)
+	}
+	attachmentFileSizeLimit, err := util.ParseSize(attachmentFileSizeLimitStr)
+	if err != nil {
+		return fmt.Errorf("invalid attachment file size limit: %s", attachmentFileSizeLimitStr)
+	}
+	visitorAttachmentTotalSizeLimit, err := util.ParseSize(visitorAttachmentTotalSizeLimitStr)
+	if err != nil {
+		return fmt.Errorf("invalid visitor attachment total size limit: %s", visitorAttachmentTotalSizeLimitStr)
+	}
+	visitorAttachmentDailyBandwidthLimit, err := util.ParseSize(visitorAttachmentDailyBandwidthLimitStr)
+	if err != nil {
+		return fmt.Errorf("invalid visitor attachment daily bandwidth limit: %s", visitorAttachmentDailyBandwidthLimitStr)
+	} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
+		return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
+	}
+
 	// Check values
 	if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
 		return errors.New("if set, FCM key file must exist")
@@ -213,10 +274,15 @@ func execServe(c *cli.Context) error {
 		return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
 	} else if attachmentCacheDir != "" && baseURL == "" {
 		return errors.New("if attachment-cache-dir is set, base-url must also be set")
-	} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
-		return errors.New("if set, base-url must start with http:// or https://")
-	} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
-		return errors.New("if set, base-url must not end with a slash (/)")
+	} else if baseURL != "" {
+		u, err := url.Parse(baseURL)
+		if err != nil {
+			return fmt.Errorf("if set, base-url must be a valid URL, e.g. https://ntfy.mydomain.com: %v", err)
+		} else if u.Scheme != "http" && u.Scheme != "https" {
+			return errors.New("if set, base-url must be a valid URL starting with http:// or https://, e.g. https://ntfy.mydomain.com")
+		} else if u.Path != "" {
+			return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path)
+		}
 	} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
 		return errors.New("if set, upstream-base-url must start with http:// or https://")
 	} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
@@ -233,6 +299,11 @@ func execServe(c *cli.Context) error {
 		return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
 	} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
 		return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
+	} else if messageSizeLimit > server.DefaultMessageSizeLimit {
+		log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients")
+		if messageSizeLimit > 5*1024*1024 {
+			return errors.New("message-size-limit cannot be higher than 5M")
+		}
 	}
 
 	// Backwards compatibility
@@ -257,26 +328,6 @@ func execServe(c *cli.Context) error {
 		listenHTTP = ""
 	}
 
-	// Convert sizes to bytes
-	attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit)
-	if err != nil {
-		return err
-	}
-	attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit)
-	if err != nil {
-		return err
-	}
-	visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit)
-	if err != nil {
-		return err
-	}
-	visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
-	if err != nil {
-		return err
-	} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
-		return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
-	}
-
 	// Resolve hosts
 	visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
 	for _, host := range visitorRequestLimitExemptHosts {
@@ -337,6 +388,8 @@ func execServe(c *cli.Context) error {
 	conf.TwilioAuthToken = twilioAuthToken
 	conf.TwilioPhoneNumber = twilioPhoneNumber
 	conf.TwilioVerifyService = twilioVerifyService
+	conf.MessageSizeLimit = int(messageSizeLimit)
+	conf.MessageDelayMax = messageDelayLimit
 	conf.TotalTopicLimit = totalTopicLimit
 	conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
 	conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
@@ -379,17 +432,6 @@ func execServe(c *cli.Context) error {
 	return nil
 }
 
-func parseSize(s string, defaultValue int64) (v int64, err error) {
-	if s == "" {
-		return defaultValue, nil
-	}
-	v, err = util.ParseSize(s)
-	if err != nil {
-		return 0, err
-	}
-	return v, nil
-}
-
 func sigHandlerConfigReload(config string) {
 	sigs := make(chan os.Signal, 1)
 	signal.Notify(sigs, syscall.SIGHUP)

+ 3 - 3
cmd/tier.go

@@ -366,9 +366,9 @@ func printTier(c *cli.Context, tier *user.Tier) {
 	fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
 	fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
 	fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
-	fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
-	fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
+	fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
+	fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
 	fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
-	fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
+	fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
 	fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
 }

+ 38 - 22
docs/config.md

@@ -404,10 +404,10 @@ with the given username/password. Be sure to use HTTPS to avoid eavesdropping an
     ```
 
 ### Example: UnifiedPush
-[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …) 
-has anonymous write access to the [topic](https://unifiedpush.org/spec/definitions/#endpoint) used for push messages. 
+[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/developers/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …) 
+has anonymous write access to the [topic](https://unifiedpush.org/developers/spec/definitions/#endpoint) used for push messages. 
 The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the 
-**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users)** for more details.
+**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users-acl)** for more details.
 
 To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either 
 allow anonymous write access for the entire prefix or explicitly per topic:
@@ -995,6 +995,15 @@ are the easiest), and then configure the following options:
 After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
 and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
 
+## Message limits
+There are a few message limits that you can configure:
+
+* `message-size-limit` defines the max size of a message body. Please note message sizes >4K are **not recommended,
+   and largely untested**. The Android/iOS and other clients may not work, or work properly. If FCM and/or APNS is used,
+   the limit should stay 4K, because their limits are around that size. If you increase this size limit regardless, 
+   FCM and APNS will NOT work for large messages.
+* `message-delay-limit` defines the max delay of a message when using the "Delay" header and [scheduled delivery](publish.md#scheduled-delivery).
+
 ## Rate limiting
 !!! info
     Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. 
@@ -1078,20 +1087,23 @@ By default, ntfy puts almost all rate limits on the message publisher, e.g. numb
 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.
+If subscriber-based rate limiting is enabled, **messages published on UnifiedPush topics** (topics starting with `up`, e.g. `up123456789012`) 
+will be counted towards the "rate visitor" of the topic. A "rate visitor" is the first subscriber to the topic. 
 
-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.
+Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as
+a "rate visitor", i.e. the visitor whose rate limits will be used 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`
+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`.
 
+!!! info
+    Due to a [denial-of-service issue](https://github.com/binwiederhier/ntfy/issues/1048), support for the `Rate-Topics`
+    header was removed entirely. This is unfortunate, but subscriber-based rate limiting will still work for `up*` topics.
+
 ## Tuning for scale
 If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
 if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
@@ -1388,6 +1400,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 | `twilio-verify-service`                    | `NTFY_TWILIO_VERIFY_SERVICE`                    | *string*                                            | -                 | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586                                                                                                                                                              |
 | `keepalive-interval`                       | `NTFY_KEEPALIVE_INTERVAL`                       | *duration*                                          | 45s               | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
 | `manager-interval`                         | `NTFY_MANAGER_INTERVAL`                         | *duration*                                          | 1m                | Interval in which the manager prunes old messages, deletes topics and prints the stats.                                                                                                                                         |
+| `message-size-limit`                       | `NTFY_MESSAGE_SIZE_LIMIT`                       | *size*                                              | 4K                | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages.                       |
+| `message-delay-limit`                      | `NTFY_MESSAGE_DELAY_LIMIT`                      | *duration*                                          | 3d                | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header                                                                                                        |
 | `global-topic-limit`                       | `NTFY_GLOBAL_TOPIC_LIMIT`                       | *number*                                            | 15,000            | Rate limiting: Total number of topics before the server rejects new topics.                                                                                                                                                     |
 | `upstream-base-url`                        | `NTFY_UPSTREAM_BASE_URL`                        | *URL*                                               | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers                                                                                                                   |
 | `upstream-access-token`                    | `NTFY_UPSTREAM_ACCESS_TOKEN`                    | *string*                                            | `tk_zyYLYj...`    | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth                                                                                                  |
@@ -1414,7 +1428,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
 | `web-push-email-address`                   | `NTFY_WEB_PUSH_EMAIL_ADDRESS`                   | *string*                                            | -                 | Web Push: Sender email address                                                                                                                                                                                                  |
 | `web-push-startup-queries`                 | `NTFY_WEB_PUSH_STARTUP_QUERIES`                 | *string*                                            | -                 | Web Push: SQL queries to run against subscription database at startup                                                                                                                                                           |
 
-The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.   
+The format for a *duration* is: `<number>(smhd)`, e.g. 30s, 20m, 1h or 3d.   
 The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
 
 ## Command line options
@@ -1446,7 +1460,7 @@ OPTIONS:
    --log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ]  set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
    --log-format value, --log_format value                                                                                 set log format (default: "text") [$NTFY_LOG_FORMAT]
    --log-file value, --log_file value                                                                                     set log file, default is STDOUT [$NTFY_LOG_FILE]
-   --config value, -c value                                                                                               config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
+   --config value, -c value                                                                                               config file (default: "/etc/ntfy/server.yml") [$NTFY_CONFIG_FILE]
    --base-url value, --base_url value, -B value                                                                           externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
    --listen-http value, --listen_http value, -l value                                                                     ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
    --listen-https value, --listen_https value, -L value                                                                   ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
@@ -1456,19 +1470,19 @@ OPTIONS:
    --cert-file value, --cert_file value, -E value                                                                         certificate file, if listen-https is set [$NTFY_CERT_FILE]
    --firebase-key-file value, --firebase_key_file value, -F value                                                         Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
    --cache-file value, --cache_file value, -C value                                                                       cache file used for message caching [$NTFY_CACHE_FILE]
-   --cache-duration since, --cache_duration since, -b since                                                               buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
+   --cache-duration since, --cache_duration since, -b since                                                               buffer messages for this time to allow since requests (default: "12h") [$NTFY_CACHE_DURATION]
    --cache-batch-size value, --cache_batch_size value                                                                     max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
-   --cache-batch-timeout value, --cache_batch_timeout value                                                               timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
+   --cache-batch-timeout value, --cache_batch_timeout value                                                               timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: "0s") [$NTFY_CACHE_BATCH_TIMEOUT]
    --cache-startup-queries value, --cache_startup_queries value                                                           queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
    --auth-file value, --auth_file value, -H value                                                                         auth database file used for access control [$NTFY_AUTH_FILE]
    --auth-startup-queries value, --auth_startup_queries value                                                             queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
    --auth-default-access value, --auth_default_access value, -p value                                                     default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
    --attachment-cache-dir value, --attachment_cache_dir value                                                             cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
-   --attachment-total-size-limit value, --attachment_total_size_limit value, -A value                                     limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
-   --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value                                       per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
-   --attachment-expiry-duration value, --attachment_expiry_duration value, -X value                                       duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
-   --keepalive-interval value, --keepalive_interval value, -k value                                                       interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
-   --manager-interval value, --manager_interval value, -m value                                                           interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
+   --attachment-total-size-limit value, --attachment_total_size_limit value, -A value                                     limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
+   --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value                                       per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
+   --attachment-expiry-duration value, --attachment_expiry_duration value, -X value                                       duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: "3h") [$NTFY_ATTACHMENT_EXPIRY_DURATION]
+   --keepalive-interval value, --keepalive_interval value, -k value                                                       interval of keepalive messages (default: "45s") [$NTFY_KEEPALIVE_INTERVAL]
+   --manager-interval value, --manager_interval value, -m value                                                           interval of for message pruning and stats printing (default: "1m") [$NTFY_MANAGER_INTERVAL]
    --disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ]          topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
    --web-root value, --web_root value                                                                                     sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT]
    --enable-signup, --enable_signup                                                                                       allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
@@ -1487,16 +1501,18 @@ OPTIONS:
    --twilio-auth-token value, --twilio_auth_token value                                                                   Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]
    --twilio-phone-number value, --twilio_phone_number value                                                               Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]
    --twilio-verify-service value, --twilio_verify_service value                                                           Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]
+   --message-size-limit value, --message_size_limit value                                                                 size limit for the message (see docs for limitations) (default: "4K") [$NTFY_MESSAGE_SIZE_LIMIT]
+   --message-delay-limit value, --message_delay_limit value                                                               max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT]
    --global-topic-limit value, --global_topic_limit value, -T value                                                       total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
    --visitor-subscription-limit value, --visitor_subscription_limit value                                                 number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
    --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value                               total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
    --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value                     total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
    --visitor-request-limit-burst value, --visitor_request_limit_burst value                                               initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
-   --visitor-request-limit-replenish value, --visitor_request_limit_replenish value                                       interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
+   --visitor-request-limit-replenish value, --visitor_request_limit_replenish value                                       interval at which burst limit is replenished (one per x) (default: "5s") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
    --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value                                 hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
    --visitor-message-daily-limit value, --visitor_message_daily_limit value                                               max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
    --visitor-email-limit-burst value, --visitor_email_limit_burst value                                                   initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
-   --visitor-email-limit-replenish value, --visitor_email_limit_replenish value                                           interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
+   --visitor-email-limit-replenish value, --visitor_email_limit_replenish value                                           interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
    --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting                                                 enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
    --behind-proxy, --behind_proxy, -P                                                                                     if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
    --stripe-secret-key value, --stripe_secret_key value                                                                   key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
@@ -1509,6 +1525,6 @@ OPTIONS:
    --web-push-private-key value, --web_push_private_key value                                                             private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
    --web-push-file value, --web_push_file value                                                                           file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]
    --web-push-email-address value, --web_push_email_address value                                                         e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
-   --web-push-startup-queries value, --web_push_startup-queries value                                                     queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]   
+   --web-push-startup-queries value, --web_push_startup_queries value                                                     queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
    --help, -h                                                                                                             show help
 ```

+ 1 - 1
docs/deprecations.md

@@ -1,4 +1,4 @@
-# Deprecation notices
+# Deprecations and breaking changes
 This page is used to list deprecation notices for ntfy. Deprecated commands and options will be 
 **removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated
 before the behavior is changed depends on the severity of the change, and how prominent the feature is.

+ 1 - 1
docs/develop.md

@@ -363,7 +363,7 @@ To build your own version with Firebase, you must:
 * And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
 * Then run:
 ```
-# To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
+# To build an unsigned .apk (app/build/outputs/apk/play/release/*.apk)
 ./gradlew assemblePlayRelease
 
 # To build a bundle .aab (app/play/release/*.aab)

+ 9 - 0
docs/examples.md

@@ -162,14 +162,23 @@ services:
     image: containrrr/watchtower
     environment:
       - WATCHTOWER_NOTIFICATIONS=shoutrrr
+      - WATCHTOWER_NOTIFICATION_SKIP_TITLE=True
       - WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
 ```
 
+The environment variable `WATCHTOWER_NOTIFICATION_SKIP_TITLE` is required to prevent Watchtower from [replacing the `title` query parameter](https://containrrr.dev/watchtower/notifications/#settings). If omitted, the provided notification title will not be used.
+
 Or, if you only want to send notifications using shoutrrr:
 ```
 shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
 ```
 
+Authentication tokens are also supported via the generic webhook and authorization header using this url format (replace the domain, topic and token with your own):
+
+```
+generic+https://DOMAIN/TOPIC?@authorization=Bearer+TOKEN`
+```
+
 ## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
 
 <!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->

+ 4 - 3
docs/hooks.py

@@ -1,6 +1,7 @@
 import os
 import shutil
 
-def copy_fonts(config, **kwargs):
-    site_dir = config['site_dir']
-    shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get'))
+
+def on_post_build(config, **kwargs):
+    site_dir = config["site_dir"]
+    shutil.copytree("docs/static/fonts", os.path.join(site_dir, "get"))

+ 2 - 3
docs/publish.md

@@ -738,9 +738,8 @@ Usage is pretty straight forward. You can set the delivery time using the `X-Del
 `3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`, 
 [and more](https://github.com/olebedev/when)). 
 
-As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can currently
-not be configured otherwise ([let me know](https://github.com/binwiederhier/ntfy/issues) if you'd like to change 
-these limits).
+As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured
+with the `message-delay-limit` option).
 
 For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours 
 after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled

+ 22 - 1
docs/releases.md

@@ -13,7 +13,7 @@ Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and
 
 * UI not updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)/[#402](https://github.com/binwiederhier/ntfy/issues/402), thanks to [@tcaputi](https://github.com/tcaputi))
 
-### ntfy server v2.8.0
+## ntfy server v2.8.0
 Released November 19, 2023
 
 This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the `Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally), web app crash fixes 
@@ -1313,6 +1313,27 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 ## Not released yet
 
+### ntfy server v2.9.0
+
+!!! info
+    **Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used.
+
+**Features:**
+
+* Support for larger message delays with `message-delay-limit` (see [message limits](config.md#message-limits), [#1050](https://github.com/binwiederhier/ntfy/pull/1050)/[#1019](https://github.com/binwiederhier/ntfy/issues/1019), thanks to [@MrChadMWood](https://github.com/MrChadMWood) for reporting)
+* Support for larger message body sizes with `message-size-limit` (use at your own risk, see [message limits](config.md#message-limits), [#836](https://github.com/binwiederhier/ntfy/pull/836)/[#1050](https://github.com/binwiederhier/ntfy/pull/1050), thanks to [@zhzy0077](https://github.com/zhzy0077) for implementing this, and to [@nkjshlsqja7331](https://github.com/nkjshlsqja7331) for reporting)
+
+**Bug fixes + maintenance:**
+
+* Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048)) 
+* Add non-root user to Docker image, ntfy can be run as non-root ([#967](https://github.com/binwiederhier/ntfy/pull/967)/[#966](https://github.com/binwiederhier/ntfy/issues/966), thanks to [@arahja](https://github.com/arahja))
+
+**Documentation:**
+
+* Remove `mkdocs-simple-hooks` ([#1016](https://github.com/binwiederhier/ntfy/pull/1016), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht))
+* Update Watchtower example ([#1014](https://github.com/binwiederhier/ntfy/pull/1014), thanks to [@lennart-m](https://github.com/lennart-m))
+* Fix dead links ([#1022](https://github.com/binwiederhier/ntfy/pull/1022), thanks to [@DerRockWolf](https://github.com/DerRockWolf))
+
 ### ntfy Android app v1.16.1 (UNRELEASED)
 
 **Features:**

+ 38 - 32
go.mod

@@ -5,23 +5,23 @@ go 1.21
 toolchain go1.21.3
 
 require (
-	cloud.google.com/go/firestore v1.14.0 // indirect
-	cloud.google.com/go/storage v1.35.1 // indirect
+	cloud.google.com/go/firestore v1.15.0 // indirect
+	cloud.google.com/go/storage v1.39.0 // indirect
 	github.com/BurntSushi/toml v1.3.2 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
 	github.com/emersion/go-smtp v0.18.0
 	github.com/gabriel-vasile/mimetype v1.4.3
 	github.com/gorilla/websocket v1.5.1
-	github.com/mattn/go-sqlite3 v1.14.18
+	github.com/mattn/go-sqlite3 v1.14.22
 	github.com/olebedev/when v1.0.0
 	github.com/stretchr/testify v1.8.4
-	github.com/urfave/cli/v2 v2.25.7
-	golang.org/x/crypto v0.15.0
-	golang.org/x/oauth2 v0.14.0 // indirect
-	golang.org/x/sync v0.5.0
-	golang.org/x/term v0.14.0
-	golang.org/x/time v0.4.0
-	google.golang.org/api v0.151.0
+	github.com/urfave/cli/v2 v2.27.1
+	golang.org/x/crypto v0.21.0
+	golang.org/x/oauth2 v0.18.0 // indirect
+	golang.org/x/sync v0.6.0
+	golang.org/x/term v0.18.0
+	golang.org/x/time v0.5.0
+	google.golang.org/api v0.168.0
 	gopkg.in/yaml.v2 v2.4.0
 )
 
@@ -30,19 +30,19 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
 require github.com/pkg/errors v0.9.1 // indirect
 
 require (
-	firebase.google.com/go/v4 v4.12.1
+	firebase.google.com/go/v4 v4.13.0
 	github.com/SherClockHolmes/webpush-go v1.3.0
 	github.com/microcosm-cc/bluemonday v1.0.26
-	github.com/prometheus/client_golang v1.17.0
+	github.com/prometheus/client_golang v1.19.0
 	github.com/stripe/stripe-go/v74 v74.30.0
 )
 
 require (
-	cloud.google.com/go v0.110.10 // indirect
-	cloud.google.com/go/compute v1.23.3 // indirect
+	cloud.google.com/go v0.112.1 // indirect
+	cloud.google.com/go/compute v1.25.0 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
-	cloud.google.com/go/iam v1.1.5 // indirect
-	cloud.google.com/go/longrunning v0.5.4 // indirect
+	cloud.google.com/go/iam v1.1.6 // indirect
+	cloud.google.com/go/longrunning v0.5.5 // indirect
 	github.com/AlekSi/pointer v1.2.0 // indirect
 	github.com/MicahParks/keyfunc v1.9.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
@@ -50,35 +50,41 @@ require (
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
+	github.com/felixge/httpsnoop v1.0.4 // indirect
+	github.com/go-logr/logr v1.4.1 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/golang/protobuf v1.5.3 // indirect
+	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/google/s2a-go v0.1.7 // indirect
-	github.com/google/uuid v1.4.0 // indirect
+	github.com/google/uuid v1.6.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
-	github.com/googleapis/gax-go/v2 v2.12.0 // indirect
+	github.com/googleapis/gax-go/v2 v2.12.2 // indirect
 	github.com/gorilla/css v1.0.1 // indirect
 	github.com/kr/text v0.2.0 // indirect
-	github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/prometheus/client_model v0.5.0 // indirect
-	github.com/prometheus/common v0.45.0 // indirect
-	github.com/prometheus/procfs v0.12.0 // indirect
+	github.com/prometheus/client_model v0.6.0 // indirect
+	github.com/prometheus/common v0.50.0 // indirect
+	github.com/prometheus/procfs v0.13.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/stretchr/objx v0.5.0 // indirect
-	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
+	github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
 	go.opencensus.io v0.24.0 // indirect
-	golang.org/x/net v0.18.0 // indirect
-	golang.org/x/sys v0.14.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
+	go.opentelemetry.io/otel v1.24.0 // indirect
+	go.opentelemetry.io/otel/metric v1.24.0 // indirect
+	go.opentelemetry.io/otel/trace v1.24.0 // indirect
+	golang.org/x/net v0.22.0 // indirect
+	golang.org/x/sys v0.18.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
-	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/appengine/v2 v2.0.5 // indirect
-	google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
-	google.golang.org/grpc v1.59.0 // indirect
-	google.golang.org/protobuf v1.31.0 // indirect
+	google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 // indirect
+	google.golang.org/grpc v1.62.1 // indirect
+	google.golang.org/protobuf v1.33.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )

+ 79 - 62
go.sum

@@ -1,20 +1,20 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y=
-cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
-cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
-cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
+cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
+cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4=
+cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
+cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/firestore v1.14.0 h1:8aLcKnMPoldYU3YHgu4t2exrKhLQkqaXAGqT0ljrFVw=
-cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ=
-cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
-cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
-cloud.google.com/go/longrunning v0.5.4 h1:w8xEcbZodnA2BbW6sVirkkoC+1gP8wS57EUUgGS0GVg=
-cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI=
-cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w=
-cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
-firebase.google.com/go/v4 v4.12.1 h1:tDNvobifGsx/1HSFLnM0fmNfx/CDZSgsTO2KhZtgpcs=
-firebase.google.com/go/v4 v4.12.1/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
+cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
+cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
+cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
+cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
+cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg=
+cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s=
+cloud.google.com/go/storage v1.39.0 h1:brbjUa4hbDHhpQf48tjqMaXEV+f1OGoaTmQau9tmCsA=
+cloud.google.com/go/storage v1.39.0/go.mod h1:OAEj/WZwUYjA3YHQ10/YcN9ttGuEpLwvaoyBXIPikEk=
+firebase.google.com/go/v4 v4.13.0 h1:meFz9nvDNh/FDyrEykoAzSfComcQbmnQSjoHrePRqeI=
+firebase.google.com/go/v4 v4.13.0/go.mod h1:e1/gaR6EnbQfsmTnAMx1hnz+ninJIrrr/RAh59Tpfn8=
 github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
 github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -48,8 +48,15 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
 github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
+github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -71,8 +78,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
-github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -87,12 +94,12 @@ github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3
 github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
 github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
-github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
 github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
-github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
-github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
+github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA=
+github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
@@ -101,10 +108,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
-github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
-github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
-github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
 github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
 github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w=
@@ -113,15 +118,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
-github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
+github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
+github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
-github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
-github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
-github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
-github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
-github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
+github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
+github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
+github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ=
+github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
+github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
+github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -138,19 +143,31 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
 github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
-github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
-github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
-github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
-github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
+github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
+github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
+github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
+github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
+go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
+go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
+go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
+go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
+go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw=
+go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
+go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
+go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
-golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
-golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
+golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
+golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -169,18 +186,18 @@ golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
-golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
+golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
+golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0=
-golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM=
+golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
+golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
-golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -192,14 +209,14 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
-golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8=
-golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
+golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
+golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -209,8 +226,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
-golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -223,8 +240,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-google.golang.org/api v0.151.0 h1:FhfXLO/NFdJIzQtCqjpysWwqKk8AzGWBUhMIx67cVDU=
-google.golang.org/api v0.151.0/go.mod h1:ccy+MJ6nrYFgE3WgRx/AMXOxOmU8Q4hSa+jjibzhxcg=
+google.golang.org/api v0.168.0 h1:MBRe+Ki4mMN93jhDDbpuRLjRddooArz4FeSObvUMmjY=
+google.golang.org/api v0.168.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
@@ -234,19 +251,19 @@ google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
-google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
-google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo=
-google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA=
+google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 h1:Fe8QycXyEd9mJgnwB9kmw00WgB43eQ/xYO5C6gceybQ=
+google.golang.org/genproto v0.0.0-20240304212257-790db918fca8/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI=
+google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ=
+google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
-google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
+google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
+google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -259,8 +276,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
-google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
-google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

+ 4 - 4
mkdocs.yml

@@ -65,15 +65,15 @@ markdown_extensions:
   - md_in_html
   - pymdownx.emoji:
       emoji_index: !!python/name:material.extensions.emoji.twemoji
-      emoji_generator: !!python/name:materialx.emoji.to_svg
+      emoji_generator: !!python/name:material.extensions.emoji.to_svg
+
+hooks:
+  - docs/hooks.py
 
 plugins:
   - search
   - minify:
       minify_html: true
-  - mkdocs-simple-hooks:
-      hooks:
-        on_post_build: "docs.hooks:copy_fonts"
 
 nav:
   - "Getting started": index.md

+ 0 - 1
requirements.txt

@@ -1,4 +1,3 @@
 # The documentation uses 'mkdocs', which is written in Python
 mkdocs-material
 mkdocs-minify-plugin
-mkdocs-simple-hooks

+ 10 - 9
server/config.go

@@ -12,11 +12,12 @@ import (
 const (
 	DefaultListenHTTP                           = ":80"
 	DefaultCacheDuration                        = 12 * time.Hour
+	DefaultCacheBatchTimeout                    = time.Duration(0)
 	DefaultKeepaliveInterval                    = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
 	DefaultManagerInterval                      = time.Minute
 	DefaultDelayedSenderInterval                = 10 * time.Second
-	DefaultMinDelay                             = 10 * time.Second
-	DefaultMaxDelay                             = 3 * 24 * time.Hour
+	DefaultMessageDelayMin                      = 10 * time.Second
+	DefaultMessageDelayMax                      = 3 * 24 * time.Hour
 	DefaultFirebaseKeepaliveInterval            = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery
 	DefaultFirebasePollInterval                 = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
 	DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
@@ -34,7 +35,7 @@ const (
 // - total topic limit: max number of topics overall
 // - various attachment limits
 const (
-	DefaultMessageLengthLimit       = 4096 // Bytes
+	DefaultMessageSizeLimit         = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
 	DefaultTotalTopicLimit          = 15000
 	DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
 	DefaultAttachmentFileSizeLimit  = int64(15 * 1024 * 1024)       // 15 MB
@@ -122,9 +123,9 @@ type Config struct {
 	MetricsEnable                        bool
 	MetricsListenHTTP                    string
 	ProfileListenHTTP                    string
-	MessageLimit                         int
-	MinDelay                             time.Duration
-	MaxDelay                             time.Duration
+	MessageDelayMin                      time.Duration
+	MessageDelayMax                      time.Duration
+	MessageSizeLimit                     int
 	TotalTopicLimit                      int
 	TotalAttachmentSizeLimit             int64
 	VisitorSubscriptionLimit             int
@@ -211,9 +212,9 @@ func NewConfig() *Config {
 		TwilioPhoneNumber:                    "",
 		TwilioVerifyBaseURL:                  "https://verify.twilio.com", // Override for tests
 		TwilioVerifyService:                  "",
-		MessageLimit:                         DefaultMessageLengthLimit,
-		MinDelay:                             DefaultMinDelay,
-		MaxDelay:                             DefaultMaxDelay,
+		MessageSizeLimit:                     DefaultMessageSizeLimit,
+		MessageDelayMin:                      DefaultMessageDelayMin,
+		MessageDelayMax:                      DefaultMessageDelayMax,
 		TotalTopicLimit:                      DefaultTotalTopicLimit,
 		TotalAttachmentSizeLimit:             0,
 		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit,

+ 15 - 17
server/server.go

@@ -733,7 +733,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 	if err != nil {
 		return nil, err
 	}
-	body, err := util.Peek(r.Body, s.config.MessageLimit)
+	body, err := util.Peek(r.Body, s.config.MessageSizeLimit)
 	if err != nil {
 		return nil, err
 	}
@@ -743,8 +743,8 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
 		return nil, e.With(t)
 	}
 	if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {
-		// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see
-		// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove
+		// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting.
+		// The 5xx response is because some app servers (in particular Mastodon) will remove
 		// the subscription as invalid if any 400-499 code (except 429/408) is returned.
 		// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
 		return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
@@ -996,9 +996,9 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
 		delay, err := util.ParseFutureTime(delayStr, time.Now())
 		if err != nil {
 			return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
-		} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
+		} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
 			return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
-		} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
+		} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
 			return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
 		}
 		m.Time = delay.Unix()
@@ -1182,7 +1182,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 	if err != nil {
 		return err
 	}
-	poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
+	poll, since, scheduled, filters, err := parseSubscribeParams(r)
 	if err != nil {
 		return err
 	}
@@ -1212,7 +1212,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 		}
 		return nil
 	}
-	if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
+	if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
 		return err
 	}
 	w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@@ -1278,7 +1278,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 	if err != nil {
 		return err
 	}
-	poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
+	poll, since, scheduled, filters, err := parseSubscribeParams(r)
 	if err != nil {
 		return err
 	}
@@ -1364,7 +1364,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 		}
 		return conn.WriteJSON(msg)
 	}
-	if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
+	if err := s.maybeSetRateVisitors(r, v, topics); err != nil {
 		return err
 	}
 	w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
@@ -1397,7 +1397,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 	return err
 }
 
-func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) {
+func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
 	poll = readBoolParam(r, false, "x-poll", "poll", "po")
 	scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
 	since, err = parseSince(r, poll)
@@ -1408,7 +1408,6 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
 	if err != nil {
 		return
 	}
-	rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics")
 	return
 }
 
@@ -1420,9 +1419,8 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
 // - or the topic is reserved, and v.user is the owner
 // - or the topic is not reserved, and v.user has write access
 //
-// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition
-// until the Android app will send the "Rate-Topics" header.
-func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
+// This only applies to UnifiedPush topics ("up...").
+func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic) error {
 	// Bail out if not enabled
 	if !s.config.VisitorSubscriberRateLimiting {
 		return nil
@@ -1431,7 +1429,7 @@ func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*top
 	// Make a list of topics that we'll actually set the RateVisitor on
 	eligibleRateTopics := make([]*topic, 0)
 	for _, t := range topics {
-		if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) {
+		if strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength {
 			eligibleRateTopics = append(eligibleRateTopics, t)
 		}
 	}
@@ -1756,7 +1754,7 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
 // before passing it on to the next handler. This is meant to be used in combination with handlePublish.
 func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
 	return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
-		m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead
+		m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageSizeLimit*2, false) // 2x to account for JSON format overhead
 		if err != nil {
 			return err
 		}
@@ -1814,7 +1812,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
 
 func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
 	return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
-		newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
+		newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageSizeLimit)
 		if err != nil {
 			logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
 			if e, ok := err.(*errMatrixPushkeyRejected); ok {

+ 16 - 7
server/server.yml

@@ -236,6 +236,16 @@
 # upstream-base-url:
 # upstream-access-token:
 
+# Configures message-specific limits
+#
+# - message-size-limit defines the max size of a message body. Please note message sizes >4K are NOT RECOMMENDED,
+#   and largely untested. If FCM and/or APNS is used, the limit should stay 4K, because their limits are around that size.
+#   If you increase this size limit regardless, FCM and APNS will NOT work for large messages.
+# - message-delay-limit defines the max delay of a message when using the "Delay" header.
+#
+# message-size-limit: "4k"
+# message-delay-limit: "3d"
+
 # Rate limiting: Total number of topics before the server rejects new topics.
 #
 # global-topic-limit: 15000
@@ -277,15 +287,14 @@
 
 # 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.
+# If subscriber-based rate limiting is enabled, messages published on UnifiedPush topics** (topics starting with "up")
+# will be counted towards the "rate visitor" of the topic. A "rate visitor" is the first subscriber to the topic.
 #
-# 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.
+# Once enabled, a client subscribing to UnifiedPush topics via HTTP stream, or websockets, will be automatically registered as
+# a "rate visitor", i.e. the visitor whose rate limits will be used 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 a HTTP 507 response if
+# 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

+ 2 - 2
server/server_account_test.go

@@ -718,11 +718,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
 	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
 	require.Nil(t, s.userManager.AddTier(&user.Tier{
 		Code:         "starter",
-		MessageLimit: 10,
+		MessageSizeLimit: 10,
 	}))
 	require.Nil(t, s.userManager.AddTier(&user.Tier{
 		Code:         "pro",
-		MessageLimit: 20,
+		MessageSizeLimit: 20,
 	}))
 	require.Nil(t, s.userManager.ChangeTier("phil", "starter"))
 

+ 20 - 82
server/server_test.go

@@ -1346,9 +1346,7 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 
 	// Register a UnifiedPush subscriber
-	response := request(t, s, "GET", "/up123456789012/json?poll=1", "", map[string]string{
-		"Rate-Topics": "up123456789012",
-	})
+	response := request(t, s, "GET", "/up123456789012/json?poll=1", "", nil)
 	require.Equal(t, 200, response.Code)
 
 	// Publish message to topic
@@ -1379,9 +1377,7 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 
 	// Register a UnifiedPush subscriber
-	response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
-		"Rate-Topics": "mytopic",
-	})
+	response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
 	require.Equal(t, 200, response.Code)
 
 	// Publish message to topic
@@ -1400,9 +1396,7 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 
 	// Register a UnifiedPush subscriber
-	response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
-		"Rate-Topics": "mytopic",
-	})
+	response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
 	require.Equal(t, 200, response.Code)
 
 	// Publish UnifiedPush text message
@@ -1434,9 +1428,7 @@ func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
 func TestServer_MatrixGateway_Push_Success(t *testing.T) {
 	s := newTestServer(t, newTestConfig(t))
 
-	response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
-		"Rate-Topics": "mytopic", // Register first!
-	})
+	response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
 	require.Equal(t, 200, response.Code)
 
 	notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
@@ -2266,16 +2258,14 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
 	c.VisitorSubscriberRateLimiting = true
 	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 "upAAAAAAAAAAAA" as a rate limit visitor
 	subscriber1Fn := func(r *http.Request) {
 		r.RemoteAddr = "1.2.3.4"
 	}
-	rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
-		"Rate-Topics": "subscriber1topic",
-	}, subscriber1Fn)
+	rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriber1Fn)
 	require.Equal(t, 200, rr.Code)
 	require.Equal(t, "", rr.Body.String())
-	require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String())
+	require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String())
 
 	// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
 	subscriber2Fn := func(r *http.Request) {
@@ -2289,10 +2279,10 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
 	// 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.
 	for i := 0; i < 2; i++ {
-		rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
+		rr := request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil)
 		require.Equal(t, 200, rr.Code)
 	}
-	rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil)
+	rr = request(t, s, "PUT", "/upAAAAAAAAAAAA", "some message", nil)
 	require.Equal(t, 429, rr.Code)
 
 	// Publish another 2 messages to "up012345678912" as visitor 9.9.9.9
@@ -2325,14 +2315,12 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
 	// 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) {
+	rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, 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)
+	require.Nil(t, s.topics["upAAAAAAAAAAAA"].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) {
@@ -2342,7 +2330,7 @@ func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
 	require.Equal(t, "", rr.Body.String())
 	require.Nil(t, s.topics["up012345678912"].rateVisitor)
 
-	// Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9
+	// Publish 3 messages to "upAAAAAAAAAAAA" 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)
@@ -2415,80 +2403,30 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
 	subscriberFn := func(r *http.Request) {
 		r.RemoteAddr = "1.2.3.4"
 	}
-	rr := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
-		"rate-topics": "mytopic",
-	}, subscriberFn)
+	rr := request(t, s, "GET", "/upAAAAAAAAAAAA/json?poll=1", "", nil, subscriberFn)
 	require.Equal(t, 200, rr.Code)
-	require.Equal(t, "1.2.3.4", s.topics["mytopic"].rateVisitor.ip.String())
-	require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
+	require.Equal(t, "1.2.3.4", s.topics["upAAAAAAAAAAAA"].rateVisitor.ip.String())
+	require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor)
 
 	// Publish message, observe rate visitor tokens being decreased
-	response := request(t, s, "POST", "/mytopic", "some message", nil)
+	response := request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil)
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
-	require.Equal(t, int64(1), s.topics["mytopic"].rateVisitor.messagesLimiter.Value())
-	require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
+	require.Equal(t, int64(1), s.topics["upAAAAAAAAAAAA"].rateVisitor.messagesLimiter.Value())
+	require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["upAAAAAAAAAAAA"].rateVisitor)
 
 	// Expire visitor
 	s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour)
 	s.pruneVisitors()
 
 	// Publish message again, observe that rateVisitor is not used anymore and is reset
-	response = request(t, s, "POST", "/mytopic", "some message", nil)
+	response = request(t, s, "POST", "/upAAAAAAAAAAAA", "some message", nil)
 	require.Equal(t, 200, response.Code)
 	require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
-	require.Nil(t, s.topics["mytopic"].rateVisitor)
+	require.Nil(t, s.topics["upAAAAAAAAAAAA"].rateVisitor)
 	require.Nil(t, s.visitors["ip:1.2.3.4"])
 }
 
-func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
-	c := newTestConfigWithAuthFile(t)
-	c.AuthDefault = user.PermissionDenyAll
-	c.VisitorSubscriberRateLimiting = true
-	s := newTestServer(t, c)
-
-	// Create some ACLs
-	require.Nil(t, s.userManager.AddTier(&user.Tier{
-		Code:         "test",
-		MessageLimit: 5,
-	}))
-	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
-	require.Nil(t, s.userManager.ChangeTier("ben", "test"))
-	require.Nil(t, s.userManager.AllowAccess("ben", "announcements", user.PermissionReadWrite))
-	require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
-	require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public_topic", user.PermissionReadWrite))
-
-	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
-	require.Nil(t, s.userManager.ChangeTier("phil", "test"))
-	require.Nil(t, s.userManager.AddReservation("phil", "reserved-for-phil", user.PermissionReadWrite))
-
-	// Set rate visitor as user "phil" on topic
-	// - "reserved-for-phil": Allowed, because I am the owner
-	// - "public_topic": Allowed, because it has read-write permissions for everyone
-	// - "announcements": NOT allowed, because it has read-only permissions for everyone
-	rr := request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
-		"Authorization": util.BasicAuth("phil", "phil"),
-		"Rate-Topics":   "reserved-for-phil,public_topic,announcements",
-	})
-	require.Equal(t, 200, rr.Code)
-	require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
-	require.Equal(t, "phil", s.topics["public_topic"].rateVisitor.user.Name)
-	require.Nil(t, s.topics["announcements"].rateVisitor)
-
-	// Set rate visitor as user "ben" on topic
-	// - "reserved-for-phil": NOT allowed, because I am not the owner
-	// - "public_topic": Allowed, because it has read-write permissions for everyone
-	// - "announcements": Allowed, because I have read-write permissions
-	rr = request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
-		"Authorization": util.BasicAuth("ben", "ben"),
-		"Rate-Topics":   "reserved-for-phil,public_topic,announcements",
-	})
-	require.Equal(t, 200, rr.Code)
-	require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
-	require.Equal(t, "ben", s.topics["public_topic"].rateVisitor.user.Name)
-	require.Equal(t, "ben", s.topics["announcements"].rateVisitor.user.Name)
-}
-
 func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
 	c := newTestConfigWithAuthFile(t)
 	c.AuthDefault = user.PermissionReadWrite

+ 2 - 2
server/smtp_server.go

@@ -150,8 +150,8 @@ func (s *smtpSession) Data(r io.Reader) error {
 			return err
 		}
 		body = strings.TrimSpace(body)
-		if len(body) > conf.MessageLimit {
-			body = body[:conf.MessageLimit]
+		if len(body) > conf.MessageSizeLimit {
+			body = body[:conf.MessageSizeLimit]
 		}
 		m := newDefaultMessage(s.topic, body)
 		subject := strings.TrimSpace(msg.Header.Get("Subject"))

+ 2 - 2
server/visitor.go

@@ -30,10 +30,10 @@ const (
 	visitorDefaultCallsLimit = int64(0)
 )
 
-// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
+// Constants used to convert a tier-user's MessageSizeLimit (see user.Tier) into adequate request limiter
 // values (token bucket). This is only used to increase the values in server.yml, never decrease them.
 //
-// Example: Assuming a user.Tier's MessageLimit is 10,000:
+// Example: Assuming a user.Tier's MessageSizeLimit is 10,000:
 // - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
 // - the replenish rate is 2 * 10,000 / 24 hours
 const (

+ 29 - 11
util/time.go

@@ -10,8 +10,8 @@ import (
 )
 
 var (
-	errUnparsableTime = errors.New("unable to parse time")
-	durationStrRegex  = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
+	errInvalidDuration = errors.New("unable to parse duration")
+	durationStrRegex   = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
 )
 
 const (
@@ -51,7 +51,7 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
 	if err == nil {
 		return t, nil
 	}
-	return time.Time{}, errUnparsableTime
+	return time.Time{}, errInvalidDuration
 }
 
 // ParseDuration is like time.ParseDuration, except that it also understands days (d), which
@@ -65,7 +65,7 @@ func ParseDuration(s string) (time.Duration, error) {
 	if matches != nil {
 		number, err := strconv.Atoi(matches[1])
 		if err != nil {
-			return 0, errUnparsableTime
+			return 0, errInvalidDuration
 		}
 		switch unit := matches[2][0:1]; unit {
 		case "d":
@@ -77,10 +77,28 @@ func ParseDuration(s string) (time.Duration, error) {
 		case "s":
 			return time.Duration(number) * time.Second, nil
 		default:
-			return 0, errUnparsableTime
+			return 0, errInvalidDuration
 		}
 	}
-	return 0, errUnparsableTime
+	return 0, errInvalidDuration
+}
+
+// FormatDuration formats a time.Duration into a human-readable string, e.g. "2d", "20h", "30m", "40s".
+// It rounds to the largest unit that is not zero, thereby effectively rounding down.
+func FormatDuration(d time.Duration) string {
+	if d >= 24*time.Hour {
+		return strconv.Itoa(int(d/(24*time.Hour))) + "d"
+	}
+	if d >= time.Hour {
+		return strconv.Itoa(int(d/time.Hour)) + "h"
+	}
+	if d >= time.Minute {
+		return strconv.Itoa(int(d/time.Minute)) + "m"
+	}
+	if d >= time.Second {
+		return strconv.Itoa(int(d/time.Second)) + "s"
+	}
+	return "0s"
 }
 
 func parseFromDuration(s string, now time.Time) (time.Time, error) {
@@ -88,7 +106,7 @@ func parseFromDuration(s string, now time.Time) (time.Time, error) {
 	if err == nil {
 		return now.Add(d), nil
 	}
-	return time.Time{}, errUnparsableTime
+	return time.Time{}, errInvalidDuration
 }
 
 func parseUnixTime(s string, now time.Time) (time.Time, error) {
@@ -96,7 +114,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
 	if err != nil {
 		return time.Time{}, err
 	} else if int64(t) < now.Unix() {
-		return time.Time{}, errUnparsableTime
+		return time.Time{}, errInvalidDuration
 	}
 	return time.Unix(int64(t), 0).UTC(), nil
 }
@@ -104,7 +122,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
 func parseNaturalTime(s string, now time.Time) (time.Time, error) {
 	r, err := when.EN.Parse(s, now) // returns "nil, nil" if no matches!
 	if err != nil || r == nil {
-		return time.Time{}, errUnparsableTime
+		return time.Time{}, errInvalidDuration
 	} else if r.Time.After(now) {
 		return r.Time, nil
 	}
@@ -112,9 +130,9 @@ func parseNaturalTime(s string, now time.Time) (time.Time, error) {
 	// simply append "tomorrow, " to it.
 	r, err = when.EN.Parse("tomorrow, "+s, now) // returns "nil, nil" if no matches!
 	if err != nil || r == nil {
-		return time.Time{}, errUnparsableTime
+		return time.Time{}, errInvalidDuration
 	} else if r.Time.After(now) {
 		return r.Time, nil
 	}
-	return time.Time{}, errUnparsableTime
+	return time.Time{}, errInvalidDuration
 }

+ 24 - 0
util/time_test.go

@@ -92,3 +92,27 @@ func TestParseDuration(t *testing.T) {
 	require.Nil(t, err)
 	require.Equal(t, time.Duration(0), d)
 }
+
+func TestFormatDuration(t *testing.T) {
+	values := []struct {
+		duration time.Duration
+		expected string
+	}{
+		{24 * time.Second, "24s"},
+		{56 * time.Minute, "56m"},
+		{time.Hour, "1h"},
+		{2 * time.Hour, "2h"},
+		{24 * time.Hour, "1d"},
+		{3 * 24 * time.Hour, "3d"},
+	}
+	for _, value := range values {
+		require.Equal(t, value.expected, FormatDuration(value.duration))
+		d, err := ParseDuration(FormatDuration(value.duration))
+		require.Nil(t, err)
+		require.Equalf(t, value.duration, d, "duration does not match: %v != %v", value.duration, d)
+	}
+}
+
+func TestFormatDuration_Rounded(t *testing.T) {
+	require.Equal(t, "1d", FormatDuration(47*time.Hour))
+}

+ 20 - 2
util/util.go

@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"math"
 	"math/rand"
 	"net/netip"
 	"os"
@@ -215,6 +216,8 @@ func ParseSize(s string) (int64, error) {
 		return -1, fmt.Errorf("cannot convert number %s", matches[1])
 	}
 	switch strings.ToUpper(matches[2]) {
+	case "T":
+		return int64(value) * 1024 * 1024 * 1024 * 1024, nil
 	case "G":
 		return int64(value) * 1024 * 1024 * 1024, nil
 	case "M":
@@ -226,8 +229,23 @@ func ParseSize(s string) (int64, error) {
 	}
 }
 
-// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB
+// FormatSize formats the size in a way that it can be parsed by ParseSize.
+// It does not include decimal places. Uneven sizes are rounded down.
 func FormatSize(b int64) string {
+	const unit = 1024
+	if b < unit {
+		return fmt.Sprintf("%d", b)
+	}
+	div, exp := int64(unit), 0
+	for n := b / unit; n >= unit; n /= unit {
+		div *= unit
+		exp++
+	}
+	return fmt.Sprintf("%d%c", int(math.Floor(float64(b)/float64(div))), "KMGT"[exp])
+}
+
+// FormatSizeHuman formats bytes into a human-readable notation, e.g. 2.1 MB
+func FormatSizeHuman(b int64) string {
 	const unit = 1024
 	if b < unit {
 		return fmt.Sprintf("%d bytes", b)
@@ -237,7 +255,7 @@ func FormatSize(b int64) string {
 		div *= unit
 		exp++
 	}
-	return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
+	return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGT"[exp])
 }
 
 // ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the

+ 25 - 11
util/util_test.go

@@ -110,35 +110,49 @@ func TestShortTopicURL(t *testing.T) {
 
 func TestParseSize_10GSuccess(t *testing.T) {
 	s, err := ParseSize("10G")
-	if err != nil {
-		t.Fatal(err)
-	}
+	require.Nil(t, err)
 	require.Equal(t, int64(10*1024*1024*1024), s)
 }
 
 func TestParseSize_10MUpperCaseSuccess(t *testing.T) {
 	s, err := ParseSize("10M")
-	if err != nil {
-		t.Fatal(err)
-	}
+	require.Nil(t, err)
 	require.Equal(t, int64(10*1024*1024), s)
 }
 
 func TestParseSize_10kLowerCaseSuccess(t *testing.T) {
 	s, err := ParseSize("10k")
-	if err != nil {
-		t.Fatal(err)
-	}
+	require.Nil(t, err)
 	require.Equal(t, int64(10*1024), s)
 }
 
 func TestParseSize_FailureInvalid(t *testing.T) {
 	_, err := ParseSize("not a size")
-	if err == nil {
-		t.Fatalf("expected error, but got none")
+	require.Error(t, err)
+}
+
+func TestFormatSize(t *testing.T) {
+	values := []struct {
+		size     int64
+		expected string
+	}{
+		{10, "10"},
+		{10 * 1024, "10K"},
+		{10 * 1024 * 1024, "10M"},
+		{10 * 1024 * 1024 * 1024, "10G"},
+	}
+	for _, value := range values {
+		require.Equal(t, value.expected, FormatSize(value.size))
+		s, err := ParseSize(FormatSize(value.size))
+		require.Nil(t, err)
+		require.Equalf(t, value.size, s, "size does not match: %d != %d", value.size, s)
 	}
 }
 
+func TestFormatSize_Rounded(t *testing.T) {
+	require.Equal(t, "10K", FormatSize(10*1024+999))
+}
+
 func TestSplitKV(t *testing.T) {
 	key, value := SplitKV(" key = value ", "=")
 	require.Equal(t, "key", key)

File diff ditekan karena terlalu besar
+ 268 - 259
web/package-lock.json


+ 55 - 8
web/public/static/langs/bg.json

@@ -55,14 +55,14 @@
     "notifications_attachment_open_title": "Към {{url}}",
     "notifications_attachment_copy_url_button": "Копиране на адреса",
     "notifications_attachment_open_button": "Отваряне на прикачения файл",
-    "notifications_attachment_link_expires": "препратката изтича на {{date}}",
+    "notifications_attachment_link_expires": "давността на препратката изтича на {{date}}",
     "notifications_actions_open_url_title": "Към {{url}}",
     "notifications_click_copy_url_button": "Копиране на препратка",
     "notifications_click_open_button": "Отваряне",
     "notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
-    "notifications_none_for_topic_title": "Липсват известия в темата.",
+    "notifications_none_for_topic_title": "Темата е все още празна",
     "notifications_none_for_any_title": "Липсват известия.",
-    "notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса й.",
+    "notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.",
     "notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
     "notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като направите заявка чрез методите PUT или POST ще ги получите тук.",
     "notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
@@ -85,7 +85,7 @@
     "publish_dialog_title_label": "Заглавие",
     "publish_dialog_priority_label": "Приоритет",
     "publish_dialog_click_placeholder": "Адрес, който се отваря при щракване върху известието",
-    "publish_dialog_email_placeholder": "Поща, на която да се препрати известието, напр. phil@example.com",
+    "publish_dialog_email_placeholder": "Адрес, към който да бъдат препращани известия, напр. phil@example.com",
     "publish_dialog_attach_label": "Адрес на прикачения файл",
     "publish_dialog_filename_placeholder": "Име на прикачения файл",
     "publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
@@ -155,7 +155,7 @@
     "notifications_actions_not_supported": "Действието не се поддържа от приложението за интернет",
     "action_bar_show_menu": "Показване на менюто",
     "action_bar_logo_alt": "Логотип на ntfy",
-    "action_bar_toggle_mute": "Заглушаване или пускне на известията",
+    "action_bar_toggle_mute": "Заглушаване или пускане на известията",
     "action_bar_toggle_action_menu": "Отваряне или затваряне на менюто с действията",
     "nav_button_muted": "Известията са заглушени",
     "notifications_list": "Списък с известия",
@@ -257,7 +257,7 @@
     "account_tokens_dialog_button_cancel": "Отказ",
     "account_delete_title": "Премахване на профила",
     "account_upgrade_dialog_title": "Промяна нивото на профила",
-    "account_usage_emails_title": "Изпратени съобщения",
+    "account_usage_emails_title": "Изпратени електронни писма",
     "account_usage_reservations_title": "Резервирани теми",
     "account_usage_reservations_none": "Няма резервирани теми",
     "account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен",
@@ -332,6 +332,53 @@
     "account_upgrade_dialog_tier_price_per_month": "на месец",
     "account_upgrade_dialog_button_pay_now": "Плащане и абониране",
     "account_upgrade_dialog_tier_selected_label": "Избрано",
-    "account_upgrade_dialog_button_update_subscription": "Премяна на абонамент",
-    "account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>."
+    "account_upgrade_dialog_button_update_subscription": "Промяна на абонамент",
+    "account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>.",
+    "account_tokens_table_expires_header": "Изтича",
+    "account_tokens_table_never_expires": "Никога",
+    "prefs_reservations_title": "Резервирани теми",
+    "prefs_reservations_table_click_to_subscribe": "Докоснете, за да се абонирате",
+    "prefs_reservations_dialog_title_delete": "Премахване на резервирането",
+    "prefs_reservations_table_everyone_read_only": "Аз мога да публикувам и да се абонирам, всички останали могат да се абонират",
+    "prefs_reservations_table_not_subscribed": "Без абонамент",
+    "account_tokens_table_token_header": "Код за достъп",
+    "account_tokens_table_create_token_button": "Създаване на код за достъп",
+    "account_tokens_dialog_expires_x_days": "Кодът за достъп изтича след {{days}} дена",
+    "account_tokens_dialog_expires_never": "Кодът за достъп не изтича",
+    "account_tokens_delete_dialog_title": "Премахване на код за достъп",
+    "prefs_reservations_limit_reached": "Достигнахте ограничението за брой резервирани теми.",
+    "prefs_reservations_add_button": "Добавяне на тема",
+    "prefs_reservations_delete_button": "Нулиране на правата за достъп",
+    "prefs_reservations_table": "Списък с резервирани теми",
+    "prefs_reservations_dialog_title_add": "Резервиране на тема",
+    "prefs_reservations_dialog_title_edit": "Променяне на резервирана тема",
+    "account_tokens_table_current_session": "Текущ сеанс на четеца",
+    "account_tokens_table_copied_to_clipboard": "Кодът за достъп е копиран",
+    "account_tokens_table_cannot_delete_or_edit": "Не можете да променяте или премахвате кода за достъп на текущия сеанс",
+    "account_tokens_table_last_origin_tooltip": "От адрес по IP {{ip}}, щракнете за подробности",
+    "account_tokens_dialog_title_create": "Създаване на код за достъп",
+    "account_tokens_dialog_title_edit": "Променяне на код за достъп",
+    "account_tokens_dialog_title_delete": "Премахване на код за достъп",
+    "account_tokens_dialog_label": "Етикет, напр. Известия от Radarr",
+    "account_tokens_dialog_button_create": "Създаване на код за достъп",
+    "account_tokens_dialog_button_update": "Променяне на код за достъп",
+    "account_tokens_dialog_expires_label": "Кодът за достъп изтича след",
+    "account_tokens_dialog_expires_x_hours": "Кодът за достъп изтича след {{hours}} часа",
+    "account_tokens_dialog_expires_unchanged": "Без промяна на давността",
+    "account_tokens_delete_dialog_submit_button": "Безвъзвратно премахване на код за достъп",
+    "prefs_users_description_no_sync": "Потребителите и паролите не се синхронизират заедно с профила.",
+    "prefs_users_table_cannot_delete_or_edit": "Влезлият потребител не може да бъде премахнат",
+    "prefs_reservations_table_everyone_deny_all": "Само аз мога да публикувам и да се абонирам",
+    "prefs_reservations_table_everyone_write_only": "Аз мога да публикувам и да се абонирам, всички останали могат да публикуват",
+    "prefs_reservations_table_everyone_read_write": "Всички могат да публикуват и да се абонират",
+    "reservation_delete_dialog_submit_button": "Премахване на резервирането",
+    "account_tokens_description": "Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.",
+    "account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>",
+    "prefs_reservations_dialog_description": "Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
+    "reservation_delete_dialog_action_keep_title": "Пазене на съобщения и прикачени файлове",
+    "reservation_delete_dialog_action_keep_description": "Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.",
+    "reservation_delete_dialog_action_delete_title": "Премахване на съобщения и прикачени файлове",
+    "reservation_delete_dialog_action_delete_description": "Съобщенията и прикачените файлове, които са във временната памет ще бъдат премахнати. Действието е необратимо.",
+    "prefs_reservations_description": "Тук можете да резервирате тема за собствено ползване. Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
+    "reservation_delete_dialog_description": "С премахването на резервирането вие се отказвате от собствеността върху темата и давате възможност друг потребител да я резервира. Можете да оставите или да премахнете съществуващите съобщения и прикачени файлове."
 }

+ 104 - 3
web/public/static/langs/da.json

@@ -35,7 +35,7 @@
     "action_bar_sign_up": "Opret konto",
     "message_bar_type_message": "Skriv en besked her",
     "nav_button_settings": "Indstillinger",
-    "message_bar_publish": "Offentliggør besked",
+    "message_bar_publish": "Udgiv besked",
     "nav_topics_title": "Tilmeldte emner",
     "nav_button_all_notifications": "Alle notifikationer",
     "nav_button_connecting": "forbinder",
@@ -103,7 +103,7 @@
     "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_usage_messages_title": "Udgivne beskeder",
     "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",
@@ -279,5 +279,106 @@
     "reservation_delete_dialog_action_keep_title": "Behold cachelagrede meddelelser og vedhæftede filer",
     "reservation_delete_dialog_action_delete_title": "Slet cachelagrede meddelelser og vedhæftede filer",
     "error_boundary_title": "Oh nej, ntfy brød sammen",
-    "error_boundary_description": "Dette bør naturligvis ikke ske. Det beklager vi meget.<br/>Hvis du har et øjeblik, bedes du <githubLink>rapportere dette på GitHub</githubLink>, eller give os besked via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>."
+    "error_boundary_description": "Dette bør naturligvis ikke ske. Det beklager vi meget.<br/>Hvis du har et øjeblik, bedes du <githubLink>rapportere dette på GitHub</githubLink>, eller give os besked via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
+    "account_upgrade_dialog_tier_features_no_calls": "Ingen telefonopkald",
+    "account_upgrade_dialog_billing_contact_email": "For faktureringsspørgsmål bedes du <Link>kontakte os</Link> direkte.",
+    "account_basics_tier_interval_monthly": "månedlig",
+    "publish_dialog_checkbox_publish_another": "Udgiv en anden",
+    "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daglige telefonopkald",
+    "publish_dialog_filename_placeholder": "Vedhæftet filnavn",
+    "prefs_users_description": "Tilføj/fjern brugere til dine beskyttede emner her. Vær opmærksom på, at brugernavn og adgangskode er gemt i browserens lokale lager.",
+    "account_basics_phone_numbers_dialog_number_label": "Telefonnummer",
+    "subscribe_dialog_subscribe_description": "Emner kan ikke beskyttes med adgangskode, så vælg et navn, der ikke er let at gætte. Når du har abonneret, kan du PUT/POST notifikationer.",
+    "account_basics_phone_numbers_dialog_check_verification_button": "Bekræft kode",
+    "account_upgrade_dialog_interval_yearly_discount_save_up_to": "spar op til {{discount}}%",
+    "account_upgrade_dialog_proration_info": "<strong>Proration</strong>: Når du opgraderer mellem betalte planer, vil prisforskellen blive <strong>opkrævet med det samme</strong>. Ved nedgradering til et lavere niveau, vil saldoen blive brugt til at betale for fremtidige faktureringsperioder.",
+    "account_usage_attachment_storage_title": "opbevaring af vedhæftede filer",
+    "message_bar_error_publishing": "Der opstod en fejl under udgivelse af meddelelse",
+    "publish_dialog_chip_delay_label": "Forsinke leveringen",
+    "prefs_reservations_table_not_subscribed": "Ikke abonneret",
+    "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daglige telefonopkald",
+    "account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS",
+    "prefs_reservations_table_everyone_read_only": "Jeg kan udgive og abonnere, alle kan abonnere",
+    "prefs_reservations_table_everyone_deny_all": "Kun jeg kan udgive og abonnere",
+    "publish_dialog_chip_topic_label": "Skift emne",
+    "account_basics_phone_numbers_dialog_description": "For at bruge opkaldsmeddelelsesfunktionen skal du tilføje og bekræfte mindst ét telefonnummer. Bekræftelse kan gøres via SMS eller et telefonopkald.",
+    "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserveret emne",
+    "account_upgrade_dialog_tier_features_no_reservations": "Ingen reserverede emner",
+    "publish_dialog_base_url_label": "Tjeneste-URL",
+    "prefs_users_table_cannot_delete_or_edit": "Kan ikke slette eller redigere en aktiv bruger",
+    "publish_dialog_title_no_topic": "Udgiv notifikation",
+    "publish_dialog_attach_label": "URL til vedhæftede filer",
+    "nav_button_muted": "Notifikationer slået fra",
+    "prefs_notifications_min_priority_description_x_or_higher": "Vis notifikationer hvis prioritet er {{number}} ({{name}}) eller højere",
+    "reservation_delete_dialog_description": "Fjernelse af en reservation opgiver ejerskabet over emnet og giver andre mulighed for at reservere det. Du kan beholde eller slette eksisterende beskeder og vedhæftede filer.",
+    "prefs_reservations_table_everyone_read_write": "Alle kan udgive og abonnere",
+    "account_upgrade_dialog_interval_monthly": "månedlig",
+    "account_basics_phone_numbers_no_phone_numbers_yet": "Ingen telefonnumre endnu",
+    "notifications_no_subscriptions_description": "Klik på linket \"{{linktext}}\" for at oprette eller abonnere på et emne. Derefter kan du sende beskeder via PUT eller POST, og du vil modtage notifikationer her.",
+    "publish_dialog_message_published": "Notifikation udgivet",
+    "publish_dialog_chip_call_label": "Telefon opkald",
+    "account_basics_phone_numbers_dialog_title": "Tilføj telefonnummer",
+    "account_tokens_delete_dialog_description": "Før du sletter et adgangstoken, skal du sikre dig, at ingen programmer eller scripts aktivt bruger det. <strong>Denne handling kan ikke fortrydes</strong>.",
+    "account_upgrade_dialog_billing_contact_website": "For spørgsmål om fakturering, se venligst vores <Link>hjemmeside</Link>.",
+    "account_usage_reservations_none": "Ingen reserverede emner til denne konto",
+    "account_tokens_description": "Brug adgangstokens, når du udgiver og abonnerer via ntfy API, så du ikke behøver at sende dine kontooplysninger. Tjek <Link>dokumentationen</Link> for at få mere at vide.",
+    "prefs_reservations_table": "Reserverede emner tabel",
+    "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daglig e-mail",
+    "prefs_reservations_description": "Her kan du reservere emnenavne til personlig brug. Reservering af et emne giver dig ejerskab over emnet og giver dig mulighed for at definere adgangstilladelser for andre brugere over emnet.",
+    "prefs_users_description_no_sync": "Brugere og adgangskoder er ikke synkroniseret til din konto.",
+    "nav_button_publish_message": "Udgiv notifikation",
+    "prefs_users_table_base_url_header": "Tjeneste-URL",
+    "publish_dialog_attach_reset": "Fjern URL til vedhæftede filer",
+    "account_upgrade_dialog_tier_features_messages_one": "{{messages}} daglig besked",
+    "account_upgrade_dialog_reservations_warning_one": "Det valgte niveau tillader færre reserverede emner end dit nuværende niveau. Før du ændrer dit niveau, <strong>slet venligst mindst én reservation</strong>. Du kan fjerne reservationer i <Link>Indstillinger</Link>.",
+    "error_boundary_unsupported_indexeddb_description": "ntfy-webappen har brug for IndexedDB for at fungere, og din browser understøtter ikke IndexedDB i privat browsing-tilstand.<br/><br/>Selv om dette er uheldigt, giver det heller ikke ret meget mening at bruge ntfy-webappen i privat browsing-tilstand alligevel, fordi alt er gemt i browserens lager. Du kan læse mere om det <githubLink>i dette GitHub issue</githubLink>, eller tale med os på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
+    "publish_dialog_title_placeholder": "Notifikationstitel, f.eks. Advarsel om diskplads",
+    "account_basics_tier_description": "Din kontos niveau",
+    "account_basics_phone_numbers_description": "For notifikationer via telefonopkald",
+    "account_upgrade_dialog_cancel_warning": "Dette vil <strong>annullere dit abonnement</strong> og nedgradere din konto den {{date}}. På den dato <strong>slettes</strong> emnereservationer samt meddelelser, der er gemt på serveren.",
+    "publish_dialog_chip_call_no_verified_numbers_tooltip": "Ingen verificerede telefonnumre",
+    "publish_dialog_call_label": "Telefon opkald",
+    "account_usage_calls_title": "Telefonopkald foretaget",
+    "prefs_notifications_min_priority_description_any": "Viser alle notifikationer, uanset prioritet",
+    "error_boundary_gathering_info": "Indsaml mere info…",
+    "reservation_delete_dialog_action_keep_description": "Beskeder og vedhæftede filer, der er cachelagret på serveren, bliver offentligt synlige for personer med kendskab til emnenavnet.",
+    "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopieret til udklipsholder",
+    "prefs_reservations_dialog_description": "Reservering af et emne giver dig ejerskab over emnet og giver dig mulighed for at definere adgangstilladelser for andre brugere over emnet.",
+    "publish_dialog_title_topic": "Udgiv til {{topic}}",
+    "account_basics_phone_numbers_dialog_number_placeholder": "f.eks. +4512345678",
+    "account_basics_phone_numbers_dialog_code_placeholder": "f.eks. 123456",
+    "account_basics_username_description": "Hej, der er du ❤",
+    "publish_dialog_base_url_placeholder": "Tjeneste-URL, f.eks. https://example.com",
+    "account_basics_tier_interval_yearly": "årligt",
+    "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} årligt. Faktureres månedligt.",
+    "account_basics_phone_numbers_dialog_channel_call": "Opkald",
+    "publish_dialog_attachment_limits_file_and_quota_reached": "overskrider filgrænsen og kvoten på {{fileSizeLimit}}, {{remainingBytes}} tilbage",
+    "account_upgrade_dialog_interval_yearly": "årligt",
+    "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} faktureres årligt. Spar {{save}}.",
+    "account_usage_basis_ip_description": "Brugsstatistikker og begrænsninger for denne konto er baseret på din IP-adresse, så de kan være delt med andre brugere. Ovenstående grænser er omtrentlige baseret på de eksisterende hastigheds grænser.",
+    "account_basics_password_dialog_title": "Skift kodeord",
+    "account_basics_phone_numbers_title": "Telefonnumre",
+    "account_upgrade_dialog_interval_yearly_discount_save": "spar {{discount}}%",
+    "publish_dialog_drop_file_here": "Smid filen her",
+    "prefs_reservations_table_everyone_write_only": "Jeg kan udgive og abonnere, alle kan udgive",
+    "account_tokens_table_cannot_delete_or_edit": "Kan ikke redigere eller slette nuværende sessionstoken",
+    "publish_dialog_attached_file_filename_placeholder": "Vedhæftet filnavn",
+    "subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
+    "account_upgrade_dialog_tier_price_per_month": "måned",
+    "message_bar_show_dialog": "Vis udgivelsesdialogen",
+    "account_usage_calls_none": "Der kan ikke foretages telefonopkald med denne konto",
+    "nav_upgrade_banner_description": "Reserver emner, flere beskeder og e-mails og større vedhæftede filer",
+    "publish_dialog_call_reset": "Fjern telefon opkald",
+    "account_basics_phone_numbers_dialog_code_label": "Verifikationskode",
+    "reservation_delete_dialog_action_delete_description": "Cachelagrede beskeder og vedhæftede filer slettes permanent. Denne handling kan ikke fortrydes.",
+    "alert_grant_button": "Tillad nu",
+    "account_usage_attachment_storage_description": "{{filesize}} pr. fil, slettet efter {{expiry}}",
+    "publish_dialog_chip_click_label": "Klik på URL",
+    "account_basics_phone_numbers_dialog_verify_button_call": "Ring til mig",
+    "publish_dialog_call_item": "Ring til tlf. {{number}}",
+    "prefs_users_dialog_base_url_label": "Tjeneste-URL, f.eks. https://ntfy.sh",
+    "account_basics_phone_numbers_dialog_channel_sms": "SMS",
+    "account_delete_dialog_billing_warning": "Hvis du sletter din konto, så annulleres dit abonnement med det samme. Du vil ikke længere have adgang til faktureringspanelet.",
+    "prefs_notifications_min_priority_description_max": "Vis notifikationer, hvis prioritet er 5 (maks.)",
+    "account_upgrade_dialog_reservations_warning_other": "Det valgte niveau tillader færre reserverede emner end dit nuværende niveau. Før du ændrer dit niveau, <strong>slet venligst mindst {{count}} reservationer</strong>. Du kan fjerne reservationer i <Link>Indstillinger</Link>."
 }

+ 6 - 6
web/public/static/langs/de.json

@@ -25,7 +25,7 @@
     "notifications_click_copy_url_title": "Link-URL in Zwischenablage kopieren",
     "publish_dialog_priority_low": "Niedrige Priorität",
     "publish_dialog_message_label": "Nachricht",
-    "action_bar_unsubscribe": "Abmelden",
+    "action_bar_unsubscribe": "Abbestellen",
     "notifications_copied_to_clipboard": "In Zwischenablage kopiert",
     "notifications_loading": "Benachrichtigungen werden geladen …",
     "notifications_attachment_open_title": "Gehe zu {{url}}",
@@ -82,7 +82,7 @@
     "publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
     "publish_dialog_filename_placeholder": "Dateiname des Anhangs",
     "publish_dialog_delay_label": "Verzögerung",
-    "publish_dialog_email_placeholder": "E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z. B. phil@example.com",
+    "publish_dialog_email_placeholder": "E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z.B. phil@example.com",
     "publish_dialog_chip_click_label": "Klick-URL",
     "publish_dialog_button_cancel_sending": "Senden abbrechen",
     "publish_dialog_drop_file_here": "Datei hierher ziehen",
@@ -180,7 +180,7 @@
     "error_boundary_unsupported_indexeddb_description": "Die ntfy Web-App benötigt eine IndexedDB für eine korrekte Funktion, und Dein Browser unterstützt in privaten Tabs keinen IndexedDB.<br/><br/>Das ist zwar ärgerlich, eine Nutzung von ntfy in einem privaten Tab macht aber auch wenig Sinn da alle Daten im Browser gespeichert werden. Weitere Informationen gibt es <githubLink>in diesem GitHub-Issue</githubLink>, oder im Chat bei <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.",
     "action_bar_toggle_action_menu": "Aktionsmenü öffnen/schließen",
     "notifications_new_indicator": "Neue Benachrichtigung",
-    "publish_dialog_email_reset": "Email-Weiterleitung entfernen",
+    "publish_dialog_email_reset": "E-Mail-Weiterleitung entfernen",
     "action_bar_logo_alt": "ntfy Logo",
     "nav_button_muted": "Benachrichtigungen stummgeschaltet",
     "notifications_list_item": "Benachrichtigung",
@@ -217,7 +217,7 @@
     "signup_form_password": "Kennwort",
     "signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
     "nav_button_account": "Konto",
-    "nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & Emails, größere Anhänge",
+    "nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & E-Mails und größere Anhänge",
     "display_name_dialog_title": "Anzeigennamen ändern",
     "display_name_dialog_placeholder": "Anzeigename",
     "reserve_dialog_checkbox_label": "Thema reservieren und Zugriffsrechte konfigurieren",
@@ -245,7 +245,7 @@
     "account_basics_tier_payment_overdue": "Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.",
     "account_basics_tier_manage_billing_button": "Zahlung verwalten",
     "account_usage_messages_title": "Veröffentlichte Nachrichten",
-    "account_usage_emails_title": "Gesendete Emails",
+    "account_usage_emails_title": "Gesendete E-Mails",
     "account_usage_reservations_title": "Reservierte Themen",
     "account_usage_reservations_none": "Keine reservierten Themen für dieses Konto",
     "account_usage_attachment_storage_title": "Speicherplatz für Anhänge",
@@ -266,7 +266,7 @@
     "account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
     "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reservierte Themen",
     "account_upgrade_dialog_tier_features_messages_other": "{{messages}} Nachrichten pro Tag",
-    "account_upgrade_dialog_tier_features_emails_other": "{{emails}} Emails pro Tag",
+    "account_upgrade_dialog_tier_features_emails_other": "{{emails}} E-Mails pro Tag",
     "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei",
     "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz",
     "account_upgrade_dialog_tier_selected_label": "Ausgewählt",

+ 1 - 0
web/public/static/langs/eo.json

@@ -0,0 +1 @@
+{}

+ 29 - 13
web/public/static/langs/fi.json

@@ -60,7 +60,7 @@
     "publish_dialog_tags_placeholder": "Pilkuilla eroteltu luettelo tunnisteista, esim. varoitus, srv1-varmuuskopio",
     "account_delete_title": "Poista tili",
     "publish_dialog_attached_file_remove": "Poista liitetiedosto",
-    "nav_button_connecting": "yhdistää",
+    "nav_button_connecting": "yhdistetään",
     "account_delete_dialog_label": "Salasana",
     "subscribe_dialog_login_button_login": "Kirjaudu",
     "account_upgrade_dialog_tier_features_no_reservations": "Ei varattuja topikkeja",
@@ -89,7 +89,7 @@
     "action_bar_toggle_mute": "Hiljennä/poista hiljennys",
     "reservation_delete_dialog_submit_button": "Poista varaus",
     "account_basics_title": "Tili",
-    "nav_button_documentation": "Käyttäjä oppaat",
+    "nav_button_documentation": "Dokumentointi",
     "prefs_reservations_limit_reached": "Olet saavuttanut varattujen topikkien rajan.",
     "account_upgrade_dialog_interval_monthly": "Kuukausittain",
     "prefs_users_add_button": "Lisää käyttäjä",
@@ -142,7 +142,7 @@
     "account_basics_tier_upgrade_button": "Päivitä Pro versioon",
     "prefs_users_description_no_sync": "Käyttäjiä ja salasanoja ei ole synkronoitu tiliisi.",
     "account_tokens_dialog_title_edit": "Muokkaa käyttöoikeustunnusta",
-    "nav_button_publish_message": "Julkaisutiedot",
+    "nav_button_publish_message": "Julkaise ilmoitus",
     "prefs_users_table_base_url_header": "Palvelin URL",
     "notifications_click_copy_url_title": "Kopioi linkin URL-osoite leikepöydälle",
     "publish_dialog_attach_reset": "Poista liitteen URL-osoite",
@@ -160,7 +160,7 @@
     "publish_dialog_priority_low": "Matala tärkeys",
     "publish_dialog_priority_label": "Prioriteetti",
     "prefs_reservations_delete_button": "Poista topikin oikeudet",
-    "account_basics_tier_admin_suffix_no_tier": "(no tier)",
+    "account_basics_tier_admin_suffix_no_tier": "(e tasoa)",
     "prefs_notifications_delete_after_one_week_description": "Ilmoitukset poistetaan automaattisesti viikon kuluttua",
     "error_boundary_unsupported_indexeddb_description": "Ntfy-verkkosovellus tarvitsee IndexedDB:n toimiakseen, eikä selaimesi tue IndexedDB:tä yksityisessä selaustilassa.<br/><br/>Vaikka tämä on valitettavaa, ntfy-verkon käyttäminen ei myöskään ole kovin järkevää yksityisessä selaustilassa, koska kaikki on tallennettu selaimen tallennustilaan. Voit lukea siitä lisää <githubLink>tästä GitHub-numerosta</githubLink> tai puhua meille <discordLink>Discordissa</discordLink> tai <matrixLink>Matrixissa</matrixLink>.",
     "subscribe_dialog_subscribe_button_cancel": "Peruuta",
@@ -175,7 +175,7 @@
     "notifications_click_copy_url_button": "Kopioi linkki",
     "account_basics_tier_admin": "Admin",
     "subscribe_dialog_subscribe_title": "Tilaa topikki",
-    "nav_topics_title": "Tilatut topikit",
+    "nav_topics_title": "Tilatut aiheet",
     "prefs_notifications_sound_title": "Ilmoitusääni",
     "prefs_notifications_min_priority_default_and_higher": "Oletusprioriteetti ja korkeammat",
     "prefs_reservations_table_access_header": "Oikeudet",
@@ -232,7 +232,7 @@
     "account_basics_username_description": "Hei, se olet sinä ❤",
     "prefs_reservations_dialog_topic_label": "Topik",
     "account_basics_password_dialog_confirm_password_label": "Vahvista salasana",
-    "action_bar_reservation_edit": "Muokkaa varatopikkia",
+    "action_bar_reservation_edit": "Muokkaa varausta",
     "publish_dialog_base_url_placeholder": "Palvelun URL-osoite, esim. https://example.com",
     "prefs_users_title": "Hallinnoi käyttäjiä",
     "account_basics_tier_interval_yearly": "vuosittain",
@@ -247,7 +247,7 @@
     "publish_dialog_delay_label": "Viive",
     "error_boundary_button_copy_stack_trace": "Kopioi pinon jälki",
     "publish_dialog_button_send": "Lähetä",
-    "action_bar_reservation_delete": "Poista varatopikit",
+    "action_bar_reservation_delete": "Poista varaus",
     "publish_dialog_button_cancel_sending": "Peruuta lähetys",
     "account_tokens_dialog_title_delete": "Poista käyttöoikeustunnus",
     "account_usage_of_limit": "limiitistä {{limit}}",
@@ -260,7 +260,7 @@
     "account_upgrade_dialog_interval_yearly": "Vuosittain",
     "publish_dialog_tags_label": "Tagit",
     "signup_form_password": "Salasana",
-    "action_bar_reservation_limit_reached": "Varatopikien raja",
+    "action_bar_reservation_limit_reached": "Raja saavutettu",
     "account_upgrade_dialog_button_redirect_signup": "Kirjaudu nyt",
     "publish_dialog_click_placeholder": "URL-osoite, joka avautuu, kun ilmoitusta napsautetaan",
     "alert_not_supported_title": "Ilmoituksia ei tueta",
@@ -314,14 +314,14 @@
     "notifications_click_open_button": "Avaa linkki",
     "account_tokens_table_current_session": "Nykyinen selainistunto",
     "account_upgrade_dialog_button_pay_now": "Maksa nyt ja tilaa",
-    "nav_upgrade_banner_description": "Varaa aiheita, lisää viestejä ja sähköposteja sekä suurempia liitteitä",
+    "nav_upgrade_banner_description": "Varaa aiheita, lisää viestejä ja sähköposteja, sekä suurempia liitteitä",
     "publish_dialog_call_reset": "Poista puhelu",
     "publish_dialog_other_features": "Muut ominaisuudet:",
     "subscribe_dialog_subscribe_use_another_label": "Käytä toista palvelinta",
     "reservation_delete_dialog_action_delete_title": "Poista välimuistissa olevat viestit ja liitteet",
     "signup_error_username_taken": "Käyttäjätunnus {{username}} on jo varattu",
     "account_basics_phone_numbers_dialog_code_label": "Vahvistuskoodi",
-    "nav_button_subscribe": "Tilaa topik",
+    "nav_button_subscribe": "Tilaa aihe",
     "publish_dialog_topic_label": "Topikin nimi",
     "reservation_delete_dialog_action_delete_description": "Välimuistissa olevat viestit ja liitteet poistetaan pysyvästi. Tätä toimintoa ei voi kumota.",
     "alert_grant_button": "Myönnä nyt",
@@ -341,7 +341,7 @@
     "prefs_users_dialog_base_url_label": "Palvelin URL, esim. https://ntfy.sh",
     "account_usage_emails_title": "Sähköpostit lähetetty",
     "account_basics_phone_numbers_dialog_channel_sms": "SMS",
-    "action_bar_reservation_add": "Varalla oleva topikki",
+    "action_bar_reservation_add": "Varalla oleva aihe",
     "account_upgrade_dialog_tier_selected_label": "Valittu",
     "account_upgrade_dialog_button_update_subscription": "Päivitä tilaus",
     "notifications_attachment_file_video": "videotiedosto",
@@ -363,6 +363,22 @@
     "notifications_none_for_topic_description": "Jos haluat lähettää ilmoituksia tähän topikkiin, PUT tai POST topikin URL-osoitteeseen.",
     "notifications_none_for_any_description": "Jos haluat lähettää ilmoituksia topikkiin, PUT tai POST topikin URL-osoitteeseen. Tässä on esimerkki yhden topikin käyttämisestä.",
     "notifications_no_subscriptions_title": "Näyttää siltä, että sinulla ei ole vielä tilauksia.",
-    "notifications_none_for_topic_title": "Et ole vielä saanut ilmoituksia tästä topikista.",
-    "notifications_actions_http_request_title": "Lähetä HTTP {{method}} to {{url}}"
+    "notifications_none_for_topic_title": "Et ole vielä saanut ilmoituksia tästä aiheesta.",
+    "notifications_actions_http_request_title": "Lähetä HTTP {{method}} to {{url}}",
+    "reserve_dialog_checkbox_label": "Käänteinen aihe ja aseta pääsy",
+    "publish_dialog_progress_uploading": "Lähetetään …",
+    "publish_dialog_title_no_topic": "Julkaise ilmoitus",
+    "notifications_example": "Esimerkki",
+    "notifications_loading": "Ladataan ilmoituksia …",
+    "notifications_no_subscriptions_description": "Klikkaa \"{{linktext}}\" linkkiä luodaksesi tai tilataksesi aihe. Sen jälkeen voit lähettää viestejä PUT tai POST metodeilla ja saat ilmoituksesi täällä.",
+    "display_name_dialog_description": "Aseta vaihtoehtoinen nimi aiheelle, joka on näytetty tilaus-listassa. Tämä auttaa tunnistamaan aiheet helpommin, joilla on hankalat nimet.",
+    "publish_dialog_message_published": "Ilmoitus julkaistu",
+    "notifications_more_details": "Saadaksesi lisää tietoa, katso <websiteLink>nettisivu</websiteLink> tai <docsLink>documentointi</docsLink>.",
+    "publish_dialog_attachment_limits_quota_reached": "ylittää kiintiön, {{remainingBytes}} jäljellä",
+    "publish_dialog_title_topic": "Julkaise aiheeseen {{topic}}",
+    "display_name_dialog_placeholder": "Näyttönimi",
+    "publish_dialog_attachment_limits_file_and_quota_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan ja määrän, {{remainingBytes}} jäljellä",
+    "publish_dialog_attachment_limits_file_reached": "ylittää {{fileSizeLimit}} tiedostokoon rajan",
+    "publish_dialog_progress_uploading_detail": "Lähetetään {{loaded}}/{{total}} ({{percent}}%) …",
+    "display_name_dialog_title": "Vaihda näyttönimi"
 }

+ 23 - 1
web/public/static/langs/ro.json

@@ -101,5 +101,27 @@
     "notifications_click_open_button": "Deschide link",
     "publish_dialog_emoji_picker_show": "Alege un emoji",
     "notifications_loading": "Încărcare notificări…",
-    "publish_dialog_priority_low": "Prioritate joasă"
+    "publish_dialog_priority_low": "Prioritate joasă",
+    "signup_form_username": "Nume de utilizator",
+    "signup_form_button_submit": "Înscrie-te",
+    "common_copy_to_clipboard": "Copiază în clipboard",
+    "signup_form_toggle_password_visibility": "Schimbă vizibilitatea parolei",
+    "signup_title": "Crează un cont ntfy",
+    "signup_already_have_account": "Deja ai un cont? Autentifică-te!",
+    "login_disabled": "Autentificarea este dezactivată",
+    "signup_error_creation_limit_reached": "S-a atins limita de conturi",
+    "action_bar_toggle_action_menu": "Deschide/Închide meniul de acțiuni",
+    "action_bar_sign_up": "Înscriere",
+    "message_bar_publish": "Publică mesajul",
+    "login_link_signup": "Înscrie-te",
+    "action_bar_sign_in": "Autentificare",
+    "action_bar_reservation_edit": "Schimbă rezervarea",
+    "action_bar_reservation_delete": "Șterge rezervarea",
+    "login_form_button_submit": "Autentifică-te",
+    "signup_disabled": "Înscrierea este dezactivată",
+    "action_bar_profile_logout": "Ieșire",
+    "message_bar_show_dialog": "Arată dialogul de publicare",
+    "signup_error_username_taken": "Numele de utilizator {{username}} este deja folosit",
+    "login_title": "Autentifică-te în contul ntfy",
+    "action_bar_reservation_add": "Rezervă topicul"
 }

+ 27 - 0
web/public/static/langs/uz.json

@@ -0,0 +1,27 @@
+{
+    "signup_title": "ntfy hisobini yaratish",
+    "signup_form_password": "Parol",
+    "signup_form_confirm_password": "Parolni tasdiqlang",
+    "signup_error_username_taken": "Foydalanuvchi nomi {{username}} allaqachon foydalanilmoqda",
+    "signup_error_creation_limit_reached": "Boshqa hisob raqam ocha olmaysiz",
+    "login_title": "Ntfy hisobingizga kiring",
+    "login_form_button_submit": "Kirish",
+    "login_link_signup": "Ro'yxatdan o'tish",
+    "login_disabled": "Kirish o'chirilgan",
+    "action_bar_show_menu": "Menyuni ko'rsatish",
+    "action_bar_logo_alt": "ntfy logotipi",
+    "action_bar_settings": "Sozlamalar",
+    "action_bar_change_display_name": "Ko'rsatilgan nomni o'zgartiring",
+    "action_bar_reservation_add": "Zaxira mavzusi",
+    "common_cancel": "Bekor qilish",
+    "common_save": "Saqlash",
+    "common_add": "Qo‘shish",
+    "common_back": "Orqaga",
+    "common_copy_to_clipboard": "Xotiraga nusxalash",
+    "signup_form_username": "Foydalanuvchi nomi",
+    "signup_form_button_submit": "Ro‘yxatdan o‘tish",
+    "signup_form_toggle_password_visibility": "Parol ko‘rinishini o‘zgartirish",
+    "signup_already_have_account": "Hisobingiz bormi? Tizimga kiring!",
+    "signup_disabled": "Ro‘yxatdan o‘tish o‘chirilgan",
+    "action_bar_account": "Hisob"
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini