diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 92816e6b..e5e989b2 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -13,7 +13,7 @@ jobs:
name: Install node
uses: actions/setup-node@v2
with:
- node-version: '17'
+ node-version: '18'
-
name: Checkout code
uses: actions/checkout@v2
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index be13b96c..7341addd 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -16,7 +16,7 @@ jobs:
name: Install node
uses: actions/setup-node@v2
with:
- node-version: '17'
+ node-version: '18'
-
name: Checkout code
uses: actions/checkout@v2
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 544857c1..372a87ce 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -13,7 +13,7 @@ jobs:
name: Install node
uses: actions/setup-node@v2
with:
- node-version: '17'
+ node-version: '18'
-
name: Checkout code
uses: actions/checkout@v2
diff --git a/client/client.yml b/client/client.yml
index d3ba2722..1b81b80d 100644
--- a/client/client.yml
+++ b/client/client.yml
@@ -5,10 +5,12 @@
#
# default-host: https://ntfy.sh
-# Default username and password will be used with "ntfy publish" if no credentials are provided on command line
-# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below
-# For an empty password, use empty double-quotes ("")
-#
+# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided.
+# You can set a default token to use or a default user:password combination, but not both. For an empty password,
+# use empty double-quotes ("")
+
+# default-token:
+
# default-user:
# default-password:
@@ -30,6 +32,8 @@
# command: 'notify-send "$m"'
# user: phill
# password: mypass
+# - topic: token_topic
+# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
#
# Variables:
# Variable Aliases Description
diff --git a/client/config.go b/client/config.go
index b2efc1d0..d4337d47 100644
--- a/client/config.go
+++ b/client/config.go
@@ -12,17 +12,22 @@ const (
// Config is the config struct for a Client
type Config struct {
- DefaultHost string `yaml:"default-host"`
- DefaultUser string `yaml:"default-user"`
- DefaultPassword *string `yaml:"default-password"`
- DefaultCommand string `yaml:"default-command"`
- Subscribe []struct {
- Topic string `yaml:"topic"`
- User string `yaml:"user"`
- Password *string `yaml:"password"`
- Command string `yaml:"command"`
- If map[string]string `yaml:"if"`
- } `yaml:"subscribe"`
+ DefaultHost string `yaml:"default-host"`
+ DefaultUser string `yaml:"default-user"`
+ DefaultPassword *string `yaml:"default-password"`
+ DefaultToken string `yaml:"default-token"`
+ DefaultCommand string `yaml:"default-command"`
+ Subscribe []Subscribe `yaml:"subscribe"`
+}
+
+// Subscribe is the struct for a Subscription within Config
+type Subscribe struct {
+ Topic string `yaml:"topic"`
+ User string `yaml:"user"`
+ Password *string `yaml:"password"`
+ Token string `yaml:"token"`
+ Command string `yaml:"command"`
+ If map[string]string `yaml:"if"`
}
// NewConfig creates a new Config struct for a Client
@@ -31,6 +36,7 @@ func NewConfig() *Config {
DefaultHost: DefaultBaseURL,
DefaultUser: "",
DefaultPassword: nil,
+ DefaultToken: "",
DefaultCommand: "",
Subscribe: nil,
}
diff --git a/client/config_test.go b/client/config_test.go
index 0a71c3bb..f22e6b20 100644
--- a/client/config_test.go
+++ b/client/config_test.go
@@ -116,3 +116,25 @@ subscribe:
require.Equal(t, "phil", conf.Subscribe[0].User)
require.Nil(t, conf.Subscribe[0].Password)
}
+
+func TestConfig_DefaultToken(t *testing.T) {
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(`
+default-host: http://localhost
+default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+subscribe:
+ - topic: mytopic
+`), 0600))
+
+ conf, err := client.LoadConfig(filename)
+ require.Nil(t, err)
+ require.Equal(t, "http://localhost", conf.DefaultHost)
+ require.Equal(t, "", conf.DefaultUser)
+ require.Nil(t, conf.DefaultPassword)
+ require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken)
+ require.Equal(t, 1, len(conf.Subscribe))
+ require.Equal(t, "mytopic", conf.Subscribe[0].Topic)
+ require.Equal(t, "", conf.Subscribe[0].User)
+ require.Nil(t, conf.Subscribe[0].Password)
+ require.Equal(t, "", conf.Subscribe[0].Token)
+}
diff --git a/cmd/publish.go b/cmd/publish.go
index 21578d34..0179f9fa 100644
--- a/cmd/publish.go
+++ b/cmd/publish.go
@@ -154,8 +154,7 @@ func execPublish(c *cli.Context) error {
}
if token != "" {
options = append(options, client.WithBearerAuth(token))
- }
- if user != "" {
+ } else if user != "" {
var pass string
parts := strings.SplitN(user, ":", 2)
if len(parts) == 2 {
@@ -171,7 +170,9 @@ func execPublish(c *cli.Context) error {
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
}
options = append(options, client.WithBasicAuth(user, pass))
- } else if token == "" && conf.DefaultUser != "" && conf.DefaultPassword != nil {
+ } else if conf.DefaultToken != "" {
+ options = append(options, client.WithBearerAuth(conf.DefaultToken))
+ } else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
}
if pid > 0 {
diff --git a/cmd/publish_test.go b/cmd/publish_test.go
index 6fe2d000..a254f47d 100644
--- a/cmd/publish_test.go
+++ b/cmd/publish_test.go
@@ -5,8 +5,11 @@ import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/test"
"heckel.io/ntfy/util"
+ "net/http"
+ "net/http/httptest"
"os"
"os/exec"
+ "path/filepath"
"strconv"
"strings"
"testing"
@@ -130,7 +133,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
// Tests with NTFY_TOPIC set ////
- require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
+ t.Setenv("NTFY_TOPIC", topic)
// Test: Successful command with NTFY_TOPIC
app, _, stdout, _ = newTestApp()
@@ -147,3 +150,151 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
m = toMessage(t, stdout.String())
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
}
+
+func TestCLI_Publish_Default_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-user: philipp
+default-password: mypass
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+ require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
+ m := toMessage(t, stdout.String())
+ require.Equal(t, "triggered", m.Message)
+}
+
+func TestCLI_Publish_Default_Token(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+ require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
+ m := toMessage(t, stdout.String())
+ require.Equal(t, "triggered", m.Message)
+}
+
+func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-user: philipp
+default-password: mypass
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+ require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
+ m := toMessage(t, stdout.String())
+ require.Equal(t, "triggered", m.Message)
+}
+
+func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+ require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
+ m := toMessage(t, stdout.String())
+ require.Equal(t, "triggered", m.Message)
+}
+
+func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_FAKETOKEN01234567890FAKETOKEN
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+ require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
+ m := toMessage(t, stdout.String())
+ require.Equal(t, "triggered", m.Message)
+}
+
+func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-user: philipp
+default-password: fakepass
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+ require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
+ m := toMessage(t, stdout.String())
+ require.Equal(t, "triggered", m.Message)
+}
+
+func TestCLI_Publish_Token_And_UserPass(t *testing.T) {
+ app, _, _, _ := newTestApp()
+ err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
+ require.Error(t, err)
+ require.Equal(t, "cannot set both --user and --token", err.Error())
+}
diff --git a/cmd/subscribe.go b/cmd/subscribe.go
index bbc6fb33..3b4b4477 100644
--- a/cmd/subscribe.go
+++ b/cmd/subscribe.go
@@ -30,6 +30,7 @@ var flagsSubscribe = append(
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
+ &cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
&cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
@@ -97,11 +98,18 @@ func execSubscribe(c *cli.Context) error {
cl := client.New(conf)
since := c.String("since")
user := c.String("user")
+ token := c.String("token")
poll := c.Bool("poll")
scheduled := c.Bool("scheduled")
fromConfig := c.Bool("from-config")
topic := c.Args().Get(0)
command := c.Args().Get(1)
+
+ // Checks
+ if user != "" && token != "" {
+ return errors.New("cannot set both --user and --token")
+ }
+
if !fromConfig {
conf.Subscribe = nil // wipe if --from-config not passed
}
@@ -109,6 +117,9 @@ func execSubscribe(c *cli.Context) error {
if since != "" {
options = append(options, client.WithSince(since))
}
+ if token != "" {
+ options = append(options, client.WithBearerAuth(token))
+ }
if user != "" {
var pass string
parts := strings.SplitN(user, ":", 2)
@@ -126,9 +137,6 @@ func execSubscribe(c *cli.Context) error {
}
options = append(options, client.WithBasicAuth(user, pass))
}
- if poll {
- options = append(options, client.WithPoll())
- }
if scheduled {
options = append(options, client.WithScheduled())
}
@@ -145,6 +153,9 @@ func execSubscribe(c *cli.Context) error {
func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
for _, s := range conf.Subscribe { // may be nil
+ if auth := maybeAddAuthHeader(s, conf); auth != nil {
+ options = append(options, auth)
+ }
if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
return err
}
@@ -175,21 +186,11 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
for filter, value := range s.If {
topicOptions = append(topicOptions, client.WithFilter(filter, value))
}
- var user string
- var password *string
- if s.User != "" {
- user = s.User
- } else if conf.DefaultUser != "" {
- user = conf.DefaultUser
- }
- if s.Password != nil {
- password = s.Password
- } else if conf.DefaultPassword != nil {
- password = conf.DefaultPassword
- }
- if user != "" && password != nil {
- topicOptions = append(topicOptions, client.WithBasicAuth(user, *password))
+
+ if auth := maybeAddAuthHeader(s, conf); auth != nil {
+ topicOptions = append(topicOptions, auth)
}
+
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
if s.Command != "" {
cmds[subscriptionID] = s.Command
@@ -214,6 +215,25 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
return nil
}
+func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
+ // check for subscription token then subscription user:pass
+ if s.Token != "" {
+ return client.WithBearerAuth(s.Token)
+ }
+ if s.User != "" && s.Password != nil {
+ return client.WithBasicAuth(s.User, *s.Password)
+ }
+
+ // if no subscription token nor subscription user:pass, check for default token then default user:pass
+ if conf.DefaultToken != "" {
+ return client.WithBearerAuth(conf.DefaultToken)
+ }
+ if conf.DefaultUser != "" && conf.DefaultPassword != nil {
+ return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)
+ }
+ return nil
+}
+
func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
if command != "" {
runCommand(c, command, m)
diff --git a/cmd/subscribe_test.go b/cmd/subscribe_test.go
new file mode 100644
index 00000000..a22b0c97
--- /dev/null
+++ b/cmd/subscribe_test.go
@@ -0,0 +1,312 @@
+package cmd
+
+import (
+ "fmt"
+ "github.com/stretchr/testify/require"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-user: philipp
+default-password: mypass
+subscribe:
+ - topic: mytopic
+ token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+subscribe:
+ - topic: mytopic
+ user: philipp
+ password: mypass
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_FAKETOKEN01234567890FAKETOKEN
+subscribe:
+ - topic: mytopic
+ token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-user: fake
+default-password: password
+subscribe:
+ - topic: mytopic
+ user: philipp
+ password: mypass
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+subscribe:
+ - topic: mytopic
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-user: philipp
+default-password: mypass
+subscribe:
+ - topic: mytopic
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+subscribe:
+ - topic: mytopic
+ token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+subscribe:
+ - topic: mytopic
+ user: philipp
+ password: mypass
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_FAKETOKEN0123456789FAKETOKEN
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) {
+ message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/mytopic/json", r.URL.Path)
+ require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(message))
+ }))
+ defer server.Close()
+
+ filename := filepath.Join(t.TempDir(), "client.yml")
+ require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
+default-host: %s
+default-token: tk_FAKETOKEN01234567890FAKETOKEN
+subscribe:
+ - topic: mytopic
+ token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
+`, server.URL)), 0600))
+
+ app, _, stdout, _ := newTestApp()
+
+ require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"}))
+
+ require.Equal(t, message, strings.TrimSpace(stdout.String()))
+}
+
+func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) {
+ app, _, _, _ := newTestApp()
+ err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
+ require.Error(t, err)
+ require.Equal(t, "cannot set both --user and --token", err.Error())
+}
diff --git a/docs/hooks.py b/docs/hooks.py
new file mode 100644
index 00000000..cdb31a52
--- /dev/null
+++ b/docs/hooks.py
@@ -0,0 +1,6 @@
+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'))
diff --git a/docs/integrations.md b/docs/integrations.md
index 498a2ff4..1640570c 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -16,6 +16,7 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!**
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
+| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany |
Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability
and uptime of third party servers, so use of each server is **at your own discretion**.
@@ -75,6 +76,7 @@ and uptime of third party servers, so use of each server is **at your own discre
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
+- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js)
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
@@ -117,6 +119,7 @@ and uptime of third party servers, so use of each server is **at your own discre
## Blog + forum posts
+- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023
- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023
- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023
- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023
diff --git a/docs/publish.md b/docs/publish.md
index 231336d1..8561ef90 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -3177,10 +3177,11 @@ These limits can be changed on a per-user basis using [tiers](config.md#tiers).
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
## List of all parameters
-The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
-and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
+The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**
+when used in **HTTP headers**, and must be **lowercase** when used as **query parameters in the URL**. They are listed in the
+table in their canonical form.
-| Parameter | Aliases (case-insensitive) | Description |
+| Parameter | Aliases | Description |
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
diff --git a/docs/releases.md b/docs/releases.md
index 612ad77f..507e6e35 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -2,6 +2,38 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
+## ntfy server v2.2.0 (UNRELEASED)
+
+**Features:**
+
+* You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8))
+
+**Bug fixes + maintenance:**
+
+* `ntfy sub --poll --from-config` will now include authentication headers from client.yml (if applicable) ([#658](https://github.com/binwiederhier/ntfy/issues/658), thanks to [@wunter8](https://github.com/wunter8))
+* Docs: Removed dependency on Google Fonts in docs ([#554](https://github.com/binwiederhier/ntfy/issues/554), thanks to [@bt90](https://github.com/bt90) for reporting, and [@ozskywalker](https://github.com/ozskywalker) for implementing)
+* Increase allowed auth failure attempts per IP address to 30 (no ticket)
+* Web app: Increase maximum incremental backoff retry interval to 2 minutes (no ticket)
+
+**Documentation:**
+
+* Make query parameter description more clear ([#630](https://github.com/binwiederhier/ntfy/issues/630), thanks to [@bbaa-bbaa](https://github.com/bbaa-bbaa) for reporting, and to [@wunter8](https://github.com/wunter8) for a fix)
+
+## ntfy Android app v1.16.1 (UNRELEASED)
+
+**Features:**
+
+* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
+
+**Bug fixes + maintenance:**
+
+* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
+* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
+
+**Additional languages:**
+
+* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
+
## ntfy server v2.1.2
Released March 4, 2023
diff --git a/docs/static/css/extra.css b/docs/static/css/extra.css
index 0329b352..3104da16 100644
--- a/docs/static/css/extra.css
+++ b/docs/static/css/extra.css
@@ -3,6 +3,8 @@
--md-primary-fg-color--light: #338574;
--md-primary-fg-color--dark: #338574;
--md-footer-bg-color: #353744;
+ --md-text-font: "Roboto";
+ --md-code-font: "Roboto Mono";
}
.md-header__button.md-logo :is(img, svg) {
@@ -147,3 +149,57 @@ figure video {
.lightbox .close-lightbox:hover::before {
background-color: #fff;
}
+
+/* roboto-300 - latin */
+@font-face {
+ font-display: swap;
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2');
+}
+
+/* roboto-regular - latin */
+@font-face {
+ font-display: swap;
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2');
+}
+
+/* roboto-italic - latin */
+@font-face {
+ font-display: swap;
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2');
+}
+
+/* roboto-500 - latin */
+@font-face {
+ font-display: swap;
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 500;
+ src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2');
+}
+
+/* roboto-700 - latin */
+@font-face {
+ font-display: swap;
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2');
+}
+
+/* roboto-mono - latin */
+@font-face {
+ font-display: swap;
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 400;
+ src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
+}
diff --git a/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 b/docs/static/fonts/roboto-mono-v22-latin-regular.woff2
new file mode 100644
index 00000000..f8894bab
Binary files /dev/null and b/docs/static/fonts/roboto-mono-v22-latin-regular.woff2 differ
diff --git a/docs/static/fonts/roboto-v30-latin-300.woff2 b/docs/static/fonts/roboto-v30-latin-300.woff2
new file mode 100644
index 00000000..60681387
Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-300.woff2 differ
diff --git a/docs/static/fonts/roboto-v30-latin-500.woff2 b/docs/static/fonts/roboto-v30-latin-500.woff2
new file mode 100644
index 00000000..29342a8d
Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-500.woff2 differ
diff --git a/docs/static/fonts/roboto-v30-latin-700.woff2 b/docs/static/fonts/roboto-v30-latin-700.woff2
new file mode 100644
index 00000000..771fbecc
Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-700.woff2 differ
diff --git a/docs/static/fonts/roboto-v30-latin-italic.woff2 b/docs/static/fonts/roboto-v30-latin-italic.woff2
new file mode 100644
index 00000000..e1b7a79f
Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-italic.woff2 differ
diff --git a/docs/static/fonts/roboto-v30-latin-regular.woff2 b/docs/static/fonts/roboto-v30-latin-regular.woff2
new file mode 100644
index 00000000..020729ef
Binary files /dev/null and b/docs/static/fonts/roboto-v30-latin-regular.woff2 differ
diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md
index f1f9e760..59cfc8e7 100644
--- a/docs/subscribe/cli.md
+++ b/docs/subscribe/cli.md
@@ -254,13 +254,13 @@ I hope this shows how powerful this command is. Here's a short video that demons
Execute all the things
-If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the
-`client.yml`. If a subscription does not specify a username/password to use or does not have a command, the defaults will be used, otherwise, the subscription settings will
-override the defaults.
+If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both).
+You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults
+will be used, otherwise, the subscription settings will override the defaults.
!!! warning
- Because the `default-user` and `default-password` will be sent for each topic that does not have its own username/password (even if the topic does not require authentication),
- be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
+ Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not
+ require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
### Using the systemd service
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
diff --git a/mkdocs.yml b/mkdocs.yml
index e3a0d507..66fc4c84 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -9,6 +9,7 @@ edit_uri: blob/main/docs/
theme:
name: material
+ font: false
language: en
custom_dir: docs/_overrides
logo: static/img/ntfy.png
@@ -70,6 +71,9 @@ plugins:
- search
- minify:
minify_html: true
+ - mkdocs-simple-hooks:
+ hooks:
+ on_post_build: "docs.hooks:copy_fonts"
nav:
- "Getting started": index.md
diff --git a/requirements.txt b/requirements.txt
index 9c2212a8..17b0fc1a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
# The documentation uses 'mkdocs', which is written in Python
mkdocs-material
mkdocs-minify-plugin
+mkdocs-simple-hooks
diff --git a/server/config.go b/server/config.go
index 75290223..dd161e4c 100644
--- a/server/config.go
+++ b/server/config.go
@@ -49,7 +49,7 @@ const (
DefaultVisitorEmailLimitReplenish = time.Hour
DefaultVisitorAccountCreationLimitBurst = 3
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
- DefaultVisitorAuthFailureLimitBurst = 10
+ DefaultVisitorAuthFailureLimitBurst = 30
DefaultVisitorAuthFailureLimitReplenish = time.Minute
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
diff --git a/server/server.go b/server/server.go
index 389601b1..8454869c 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1642,6 +1642,7 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
// if it is set.
//
+// - If auth-file is not configured, immediately return an IP-based visitor
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
// an IP-based visitor is returned
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
@@ -1653,13 +1654,14 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
// Read "Authorization" header value, and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy)
vip := s.visitor(ip, nil)
+ if s.userManager == nil {
+ return vip, nil
+ }
header, err := readAuthHeader(r)
if err != nil {
return vip, err
} else if !supportedAuthHeader(header) {
return vip, nil
- } else if s.userManager == nil {
- return vip, errHTTPUnauthorized
}
// If we're trying to auth, check the rate limiter first
if !vip.AuthAllowed() {
diff --git a/server/server_test.go b/server/server_test.go
index 032ec6ff..fdda5d96 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -796,6 +796,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) {
c := newTestConfigWithAuthFile(t)
+ c.VisitorAuthFailureLimitBurst = 10
s := newTestServer(t, c)
for i := 0; i < 10; i++ {
diff --git a/user/manager_test.go b/user/manager_test.go
index f242af71..cd2e1032 100644
--- a/user/manager_test.go
+++ b/user/manager_test.go
@@ -133,29 +133,6 @@ func TestManager_AddUser_And_Query(t *testing.T) {
require.Equal(t, u.ID, u3.ID)
}
-func TestManager_Authenticate_Timing(t *testing.T) {
- a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
- require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
-
- // Timing a correct attempt
- start := time.Now().UnixMilli()
- _, err := a.Authenticate("user", "pass")
- require.Nil(t, err)
- require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
-
- // Timing an incorrect attempt
- start = time.Now().UnixMilli()
- _, err = a.Authenticate("user", "INCORRECT")
- require.Equal(t, ErrUnauthenticated, err)
- require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
-
- // Timing a non-existing user attempt
- start = time.Now().UnixMilli()
- _, err = a.Authenticate("DOES-NOT-EXIST", "hithere")
- require.Equal(t, ErrUnauthenticated, err)
- require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
-}
-
func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
diff --git a/web/public/static/css/fonts.css b/web/public/static/css/fonts.css
index d14bad03..4245d0f5 100644
--- a/web/public/static/css/fonts.css
+++ b/web/public/static/css/fonts.css
@@ -6,8 +6,7 @@
font-style: normal;
font-weight: 300;
src: local(''),
- url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
- url('../fonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url('../fonts/roboto-v29-latin-300.woff2') format('woff2');
}
/* roboto-regular - latin */
@@ -16,8 +15,7 @@
font-style: normal;
font-weight: 400;
src: local(''),
- url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
- url('../fonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url('../fonts/roboto-v29-latin-regular.woff2') format('woff2');
}
/* roboto-500 - latin */
@@ -26,8 +24,7 @@
font-style: normal;
font-weight: 500;
src: local(''),
- url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
- url('../fonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url('../fonts/roboto-v29-latin-500.woff2') format('woff2');
}
/* roboto-700 - latin */
@@ -36,6 +33,5 @@
font-style: normal;
font-weight: 700;
src: local(''),
- url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
- url('../fonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url('../fonts/roboto-v29-latin-700.woff2') format('woff2');
}
diff --git a/web/public/static/fonts/roboto-v29-latin-300.woff b/web/public/static/fonts/roboto-v29-latin-300.woff
deleted file mode 100644
index 5565042e..00000000
Binary files a/web/public/static/fonts/roboto-v29-latin-300.woff and /dev/null differ
diff --git a/web/public/static/fonts/roboto-v29-latin-500.woff b/web/public/static/fonts/roboto-v29-latin-500.woff
deleted file mode 100644
index c9eb5cab..00000000
Binary files a/web/public/static/fonts/roboto-v29-latin-500.woff and /dev/null differ
diff --git a/web/public/static/fonts/roboto-v29-latin-700.woff b/web/public/static/fonts/roboto-v29-latin-700.woff
deleted file mode 100644
index a5d98fc6..00000000
Binary files a/web/public/static/fonts/roboto-v29-latin-700.woff and /dev/null differ
diff --git a/web/public/static/fonts/roboto-v29-latin-regular.woff b/web/public/static/fonts/roboto-v29-latin-regular.woff
deleted file mode 100644
index 86b38637..00000000
Binary files a/web/public/static/fonts/roboto-v29-latin-regular.woff and /dev/null differ
diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json
index 83663794..d6634681 100644
--- a/web/public/static/langs/ar.json
+++ b/web/public/static/langs/ar.json
@@ -56,12 +56,12 @@
"publish_dialog_title_topic": "أنشُر إلى {{topic}}",
"publish_dialog_title_no_topic": "انشُر الإشعار",
"publish_dialog_emoji_picker_show": "اختر رمزًا تعبيريًا",
- "publish_dialog_priority_min": "الحد الأدنى للأولوية",
+ "publish_dialog_priority_min": "أولوية دنيا",
"publish_dialog_priority_low": "أولوية منخفضة",
"publish_dialog_priority_default": "الأولوية الافتراضية",
"publish_dialog_priority_high": "أولوية عالية",
"publish_dialog_base_url_label": "الرابط التشعبي للخدمة",
- "publish_dialog_priority_max": "الأولوية القصوى",
+ "publish_dialog_priority_max": "أولوية قصوى",
"publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
"publish_dialog_title_label": "العنوان",
"publish_dialog_title_placeholder": "عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص",
@@ -154,7 +154,7 @@
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
"subscribe_dialog_login_button_back": "العودة",
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
- "prefs_notifications_min_priority_title": "الحد الأدنى للأولوية",
+ "prefs_notifications_min_priority_title": "أولوية دنيا",
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
"notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.",
"publish_dialog_click_label": "الرابط التشعبي URL للنقر",
@@ -296,5 +296,38 @@
"prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات المرور مع حسابك.",
"reservation_delete_dialog_action_delete_description": "سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.",
"notifications_actions_http_request_title": "إرسال طلب HTTP {{method}} إلى {{url}}",
- "notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك."
+ "notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.",
+ "error_boundary_description": "من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا.
إن كان لديك دقيقة، يرجى الإبلاغ عن ذلك على GitHub ، أو إعلامنا عبر Discord أو Matrix .",
+ "nav_button_muted": "الإشعارات المكتومة",
+ "priority_min": "دنيا",
+ "signup_error_username_taken": "تم حجز اسم المستخدم {{username}} مِن قَبلُ",
+ "action_bar_reservation_limit_reached": "بلغت الحد الأقصى",
+ "prefs_reservations_delete_button": "إعادة تعيين الوصول إلى الموضوع",
+ "prefs_reservations_edit_button": "تعديل الوصول إلى موضوع",
+ "prefs_reservations_limit_reached": "لقد بلغت الحد الأقصى من المواضيع المحجوزة.",
+ "reservation_delete_dialog_action_keep_description": "ستصبح الرسائل والمرفقات المخزنة مؤقتًا على الخادم مرئية للعموم وللأشخاص الذين لديهم معرفة باسم الموضوع.",
+ "reservation_delete_dialog_description": "تؤدي إزالة الحجز إلى التخلي عن ملكية الموضوع، مما يسمح للآخرين بحجزه. يمكنك الاحتفاظ بالرسائل والمرفقات الموجودة أو حذفها.",
+ "prefs_reservations_dialog_description": "يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات وصول المستخدمين الآخرين إليه.",
+ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "توفير ما يصل إلى {{discount}}٪",
+ "account_upgrade_dialog_interval_monthly": "شهريا",
+ "account_upgrade_dialog_tier_features_attachment_total_size": "إجمالي مساحة التخزين {{totalsize}}",
+ "publish_dialog_progress_uploading_detail": "تحميل {{loaded}}/{{total}} ({{percent}}٪) …",
+ "account_basics_tier_interval_monthly": "شهريا",
+ "account_basics_tier_interval_yearly": "سنويا",
+ "account_upgrade_dialog_tier_features_reservations": "{{reservations}} مواضيع محجوزة",
+ "account_upgrade_dialog_billing_contact_website": "للأسئلة المتعلقة بالفوترة، يرجى الرجوع إلى موقعنا على الويب.",
+ "prefs_notifications_min_priority_description_x_or_higher": "إظهار الإشعارات إذا كانت الأولوية {{number}} ({{name}}) أو أعلى",
+ "account_upgrade_dialog_billing_contact_email": "للأسئلة المتعلقة بالفوترة، الرجاء الاتصال بنا مباشرة.",
+ "account_upgrade_dialog_tier_selected_label": "المحدد",
+ "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} لكل ملف",
+ "account_upgrade_dialog_interval_yearly": "سنويا",
+ "account_upgrade_dialog_tier_features_no_reservations": "لا توجد مواضيع محجوزة",
+ "account_upgrade_dialog_interval_yearly_discount_save": "وفر {{discount}}٪",
+ "publish_dialog_click_reset": "إزالة الرابط التشعبي URL للنقر",
+ "prefs_notifications_min_priority_description_max": "إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)",
+ "publish_dialog_attachment_limits_file_reached": "يتجاوز الحد الأقصى للملف {{fileSizeLimit}}",
+ "publish_dialog_attachment_limits_quota_reached": "يتجاوز الحصة، {{remainingBytes}} متبقية",
+ "account_basics_tier_paid_until": "تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا",
+ "account_basics_tier_canceled_subscription": "تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.",
+ "account_delete_dialog_billing_warning": "إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن."
}
diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json
index 11987f86..5f417357 100644
--- a/web/public/static/langs/bg.json
+++ b/web/public/static/langs/bg.json
@@ -228,5 +228,25 @@
"account_basics_username_description": "Хей, това сте вие ❤",
"account_basics_username_admin_tooltip": "Вие сте администратор",
"account_basics_password_title": "Парола",
- "account_delete_dialog_label": "Парола"
+ "account_delete_dialog_label": "Парола",
+ "account_basics_password_dialog_title": "Смяна на парола",
+ "account_basics_password_dialog_current_password_label": "Текуща парола",
+ "account_basics_password_dialog_new_password_label": "Нова парола",
+ "account_basics_password_dialog_confirm_password_label": "Парола отново",
+ "account_basics_password_dialog_button_submit": "Смяна на парола",
+ "account_usage_title": "Употреба",
+ "account_usage_of_limit": "от {{limit}}",
+ "account_usage_unlimited": "Неограничено",
+ "account_usage_limits_reset_daily": "Ограниченията се нулират всеки ден в полунощ (UTC)",
+ "account_basics_tier_interval_monthly": "месечно",
+ "account_basics_tier_interval_yearly": "годишно",
+ "account_basics_password_description": "Промяна на паролата на профила",
+ "account_basics_tier_title": "Вид на профила",
+ "account_basics_tier_admin": "Администратор",
+ "account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)",
+ "account_basics_tier_admin_suffix_no_tier": "(без ниво)",
+ "account_basics_tier_free": "безплатен",
+ "account_basics_tier_basic": "базов",
+ "account_basics_tier_change_button": "Променяне",
+ "account_basics_tier_paid_until": "Абонаментът е платен до {{date}} и автоматично ще се поднови"
}
diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json
index 93e9abb6..032e8b7a 100644
--- a/web/public/static/langs/cs.json
+++ b/web/public/static/langs/cs.json
@@ -285,7 +285,7 @@
"account_delete_dialog_button_submit": "Trvale odstranit účet",
"account_delete_dialog_billing_warning": "Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.",
"account_upgrade_dialog_title": "Změna úrovně účtu",
- "account_upgrade_dialog_proration_info": "Prohlášení: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně účtován nebo vrácen v následující faktuře. Další fakturu obdržíte až na konci dalšího zúčtovacího období.",
+ "account_upgrade_dialog_proration_info": "Prohlášení: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně zaúčtován okamžitě. Při přechodu na nižší úroveň se zůstatek použije na platbu za budoucí zúčtovací období.",
"account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, odstraňte alespoň jednu rezervaci. Rezervace můžete odstranit v Nastavení.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} rezervovaných témat",
"account_upgrade_dialog_tier_features_messages": "{{messages}} denních zpráv",
@@ -340,5 +340,17 @@
"reservation_delete_dialog_action_keep_description": "Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.",
"reservation_delete_dialog_action_delete_title": "Odstranění zpráv a příloh uložených v mezipaměti",
"reservation_delete_dialog_action_delete_description": "Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.",
- "reservation_delete_dialog_submit_button": "Odstranit rezervaci"
+ "reservation_delete_dialog_submit_button": "Odstranit rezervaci",
+ "account_basics_tier_interval_yearly": "roční",
+ "account_upgrade_dialog_interval_yearly_discount_save": "ušetříte {{discount}}%",
+ "account_upgrade_dialog_tier_price_per_month": "měsíc",
+ "account_upgrade_dialog_tier_features_no_reservations": "Žádná rezervovaná témata",
+ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "ušetříte až {{discount}}%",
+ "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} účtováno ročně. Ušetříte {{save}}.",
+ "account_basics_tier_interval_monthly": "měsíční",
+ "account_upgrade_dialog_interval_monthly": "Měsíční",
+ "account_upgrade_dialog_interval_yearly": "Roční",
+ "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} za rok. Účtuje se měsíčně.",
+ "account_upgrade_dialog_billing_contact_email": "V případě dotazů týkajících se fakturace nás prosím kontaktujte přímo.",
+ "account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich webových stránkách."
}
diff --git a/web/public/static/langs/da.json b/web/public/static/langs/da.json
index 2f871f53..276e442f 100644
--- a/web/public/static/langs/da.json
+++ b/web/public/static/langs/da.json
@@ -221,5 +221,63 @@
"account_tokens_delete_dialog_submit_button": "Slet token permanent",
"prefs_notifications_delete_after_one_month": "Efter en måned",
"prefs_notifications_delete_after_one_week": "Efter en uge",
- "prefs_users_dialog_username_label": "Brugernavn, f.eks. phil"
+ "prefs_users_dialog_username_label": "Brugernavn, f.eks. phil",
+ "prefs_notifications_delete_after_one_day_description": "Notifikationer slettes automatisk efter en dag",
+ "notifications_none_for_topic_description": "For at sende en notifikation til dette emne, skal du blot sende en PUT eller POST til emne-URL'en.",
+ "notifications_none_for_any_description": "For at sende en notifikation til et emne, skal du blot sende en PUT eller POST til emne-URL'en. Her er et eksempel med et af dine emner.",
+ "notifications_no_subscriptions_title": "Det ser ud til, at du ikke har nogen abonnementer endnu.",
+ "notifications_more_details": "For mere information, se webstedet eller dokumentationen.",
+ "display_name_dialog_description": "Angiv et alternativt navn for et emne, der vises på abonnementslisten. Dette gør det nemmere at identificere emner med komplicerede navne.",
+ "reserve_dialog_checkbox_label": "Reserver emne og konfigurer adgang",
+ "publish_dialog_attachment_limits_file_reached": "overskrider {{fileSizeLimit}} filgrænse",
+ "publish_dialog_attachment_limits_quota_reached": "overskrider kvote, {{remainingBytes}} tilbage",
+ "publish_dialog_topic_label": "Emnenavn",
+ "publish_dialog_topic_placeholder": "Emnenavn, f.eks. phil_alerts",
+ "publish_dialog_topic_reset": "Nulstil emne",
+ "publish_dialog_click_reset": "Fjern klik-URL",
+ "publish_dialog_delay_placeholder": "Forsink levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (kun på engelsk)",
+ "publish_dialog_other_features": "Andre funktioner:",
+ "publish_dialog_chip_attach_url_label": "Vedhæft fil via URL",
+ "publish_dialog_chip_attach_file_label": "Vedhæft lokal fil",
+ "publish_dialog_details_examples_description": "For eksempler og en detaljeret beskrivelse af alle afsendelsesfunktioner henvises til dokumentationen.",
+ "publish_dialog_button_cancel_sending": "Annuller afsendelse",
+ "publish_dialog_attached_file_title": "Vedhæftet fil:",
+ "emoji_picker_search_placeholder": "Søg emoji",
+ "emoji_picker_search_clear": "Ryd søgning",
+ "subscribe_dialog_subscribe_title": "Abonner på emne",
+ "subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_alerts",
+ "subscribe_dialog_subscribe_button_generate_topic_name": "Generer navn",
+ "subscribe_dialog_login_title": "Login påkrævet",
+ "subscribe_dialog_login_description": "Dette emne er adgangskodebeskyttet. Indtast venligst brugernavn og adgangskode for at abonnere.",
+ "subscribe_dialog_error_user_not_authorized": "Brugeren {{username}} er ikke autoriseret",
+ "account_basics_password_description": "Skift adgangskoden til din konto",
+ "account_usage_limits_reset_daily": "Brugsgrænser nulstilles dagligt ved midnat (UTC)",
+ "account_basics_tier_paid_until": "Abonnementet er betalt indtil {{date}} og fornys automatisk",
+ "account_basics_tier_payment_overdue": "Din betaling er forfalden. Opdater venligst din betalingsmetode, ellers bliver din konto snart nedgraderet.",
+ "account_basics_tier_canceled_subscription": "Dit abonnement blev annulleret og vil blive nedgraderet til en gratis konto den {{date}}.",
+ "account_usage_cannot_create_portal_session": "Kan ikke åbne faktureringsportalen",
+ "account_delete_description": "Slet din konto permanent",
+ "account_delete_dialog_description": "Dette vil slette din konto permanent, inklusive alle data, der er gemt på serveren. Efter sletning vil dit brugernavn være utilgængeligt i 7 dage. Hvis du virkelig ønsker at fortsætte, bedes du bekræfte med dit kodeord i feltet nedenfor.",
+ "account_upgrade_dialog_button_pay_now": "Betal nu og abonner",
+ "account_tokens_table_last_origin_tooltip": "Fra IP-adresse {{ip}}, klik for at slå op",
+ "account_tokens_dialog_label": "Label, f.eks. radarmeddelelser",
+ "account_tokens_dialog_expires_label": "Adgangstoken udløber om",
+ "account_tokens_dialog_expires_unchanged": "Lad udløbsdatoen forblive uændret",
+ "account_tokens_dialog_expires_x_hours": "Token udløber om {{hours}} timer",
+ "account_tokens_dialog_expires_x_days": "Token udløber om {{days}} dage",
+ "prefs_notifications_sound_description_none": "Notifikationer afspiller ingen lyd, når de ankommer",
+ "prefs_notifications_sound_description_some": "Notifikationer afspiller {{sound}}-lyden, når de ankommer",
+ "prefs_notifications_min_priority_low_and_higher": "Lav prioritet og højere",
+ "prefs_notifications_min_priority_default_and_higher": "Standardprioritet og højere",
+ "prefs_notifications_min_priority_high_and_higher": "Høj prioritet og højere",
+ "prefs_notifications_delete_after_never_description": "Notifikationer slettes aldrig automatisk",
+ "prefs_notifications_delete_after_three_hours_description": "Notifikationer slettes automatisk efter tre timer",
+ "prefs_notifications_delete_after_one_week_description": "Notifikationer slettes automatisk efter en uge",
+ "prefs_notifications_delete_after_one_month_description": "Notifikationer slettes automatisk efter en måned",
+ "prefs_reservations_limit_reached": "Du har nået din grænse for reserverede emner.",
+ "prefs_reservations_table_click_to_subscribe": "Klik for at abonnere",
+ "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.
Hvis du har et øjeblik, bedes du rapportere dette på GitHub, eller give os besked via Discord eller Matrix."
}
diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json
index cf7e23a1..f6a73618 100644
--- a/web/public/static/langs/de.json
+++ b/web/public/static/langs/de.json
@@ -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 die 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",
@@ -261,7 +261,7 @@
"account_usage_basis_ip_description": "Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.",
"account_delete_dialog_billing_warning": "Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.",
"account_upgrade_dialog_title": "Konto-Level ändern",
- "account_upgrade_dialog_proration_info": "Anrechnung: Wenn Du zwischen kostenpflichtigen Leveln wechselst wir die Differenz bei der nächsten Abrechnung nachberechnet oder erstattet. Du erhältst bis zum Ende der Abrechnungsperiode keine neue Rechnung.",
+ "account_upgrade_dialog_proration_info": "Anrechnung: Wenn Du auf einen höheren kostenpflichtigen Level wechselst wird die Differenz sofort berechnet. Beim Wechsel auf ein kleineres Level verwenden wir Dein Guthaben für zukünftige Abrechnungsperioden.",
"account_upgrade_dialog_reservations_warning_one": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung. Du kannst Reservierungen in den Einstellungen löschen.",
"account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen. Du kannst Reservierungen in den Einstellungen löschen.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reservierte Themen",
@@ -340,5 +340,17 @@
"nav_upgrade_banner_label": "Upgrade auf ntfy Pro",
"alert_not_supported_context_description": "Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der Notifications API.",
"display_name_dialog_description": "Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.",
- "account_basics_username_admin_tooltip": "Du bist Admin"
+ "account_basics_username_admin_tooltip": "Du bist Admin",
+ "account_upgrade_dialog_interval_yearly_discount_save": "spare {{discount}}%",
+ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "spare bis zu {{discount}}%",
+ "account_upgrade_dialog_tier_price_per_month": "Monat",
+ "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} pro Jahr. Spare {{save}}.",
+ "account_upgrade_dialog_billing_contact_email": "Bei Fragen zur Abrechnung, kontaktiere uns bitte direkt.",
+ "account_upgrade_dialog_billing_contact_website": "Bei Fragen zur Abrechnung sieh bitte auf unserer Webseite nach.",
+ "account_upgrade_dialog_tier_features_no_reservations": "Keine reservierten Themen",
+ "account_basics_tier_interval_yearly": "jährlich",
+ "account_basics_tier_interval_monthly": "monatlich",
+ "account_upgrade_dialog_interval_monthly": "Monatlich",
+ "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} pro Jahr. Monatlich abgerechnet.",
+ "account_upgrade_dialog_interval_yearly": "Jährlich"
}
diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json
index bb1014fe..b19df52b 100644
--- a/web/public/static/langs/es.json
+++ b/web/public/static/langs/es.json
@@ -293,7 +293,7 @@
"account_delete_dialog_button_submit": "Eliminar permanentemente la cuenta",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} tópicos reservados",
"account_upgrade_dialog_cancel_warning": "Esto cancelará su suscripción y degradará su cuenta en {{date}}. En esa fecha, sus tópicos reservados y sus mensajes almacenados en caché en el servidor serán eliminados.",
- "account_upgrade_dialog_proration_info": "Prorrateo: Al cambiar entre planes de pago, la diferencia de precio se cargará o reembolsará en la siguiente factura. No recibirá otra factura hasta el final del siguiente periodo de facturación.",
+ "account_upgrade_dialog_proration_info": "Prorrateo: al actualizar entre planes pagos, la diferencia de precio se cobrará de inmediato. Al cambiar a un nivel inferior, el saldo se utilizará para pagar futuros períodos de facturación.",
"account_upgrade_dialog_reservations_warning_other": "El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, por favor elimine al menos {{count}} reservaciones. Puede eliminar reservaciones en Configuración.",
"account_upgrade_dialog_tier_features_messages": "{{messages}} mensajes diarios",
"account_upgrade_dialog_tier_features_emails": "{{emails}} correos diarios",
@@ -340,5 +340,17 @@
"prefs_reservations_dialog_topic_label": "Tópico",
"reservation_delete_dialog_description": "Al eliminar una reserva se renuncia a la propiedad sobre el tópico y se permite que otros lo reserven. Puede conservar o eliminar los mensajes y archivos adjuntos existentes.",
"reservation_delete_dialog_action_delete_title": "Eliminar mensajes y archivos adjuntos en caché",
- "reservation_delete_dialog_submit_button": "Eliminar reserva"
+ "reservation_delete_dialog_submit_button": "Eliminar reserva",
+ "account_basics_tier_interval_monthly": "mensualmente",
+ "account_basics_tier_interval_yearly": "anualmente",
+ "account_upgrade_dialog_interval_monthly": "Mensualmente",
+ "account_upgrade_dialog_interval_yearly": "Anualmente",
+ "account_upgrade_dialog_interval_yearly_discount_save": "ahorrar {{discount}}%",
+ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "ahorra hasta un {{discount}}%",
+ "account_upgrade_dialog_tier_features_no_reservations": "Ningún tema reservado",
+ "account_upgrade_dialog_tier_price_per_month": "mes",
+ "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} facturado anualmente. Guardar {{save}}.",
+ "account_upgrade_dialog_billing_contact_website": "Si tiene preguntas sobre facturación, consulte nuestra página web.",
+ "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} al año. Facturación mensual.",
+ "account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor contáctenos directamente."
}
diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json
index c3b79b0c..6be8c9f8 100644
--- a/web/public/static/langs/id.json
+++ b/web/public/static/langs/id.json
@@ -256,7 +256,7 @@
"account_usage_cannot_create_portal_session": "Tidak dapat membuka portal tagihan",
"account_delete_dialog_billing_warning": "Menghapus akun Anda juga membatalkan tagihan langganan dengan segera. Anda tidak akan memiliki akses lagi ke dasbor tagihan.",
"account_upgrade_dialog_title": "Ubah peringkat akun",
- "account_upgrade_dialog_proration_info": "Prorasi: Ketika mengubah rencana berbayar, perubahan harga akan ditagih atau dikembalikan di faktur berikutnya. Anda tidak akan menerima faktur lain sampai akhir periode tagihan.",
+ "account_upgrade_dialog_proration_info": "Prorasi: Saat melakukan upgrade antar paket berbayar, selisih harga akan langsung dibebankan ke. Saat menurunkan ke tingkat yang lebih rendah, saldo akan digunakan untuk membayar periode penagihan di masa mendatang.",
"account_upgrade_dialog_reservations_warning_other": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, silakan menghapus setidaknya {{count}} reservasi. Anda dapat menghapus reservasi di Pengaturan.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} topik yang telah direservasi",
"account_upgrade_dialog_tier_features_messages": "{{messages}} pesan harian",
@@ -340,5 +340,17 @@
"prefs_reservations_dialog_description": "Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.",
"prefs_reservations_dialog_topic_label": "Topik",
"prefs_reservations_dialog_access_label": "Akses",
- "reservation_delete_dialog_description": "Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya."
+ "reservation_delete_dialog_description": "Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya.",
+ "account_upgrade_dialog_interval_yearly": "Setiap tahun",
+ "account_upgrade_dialog_tier_price_billed_yearly": "Ditagih {{price}} setiap tahun. Hemat {{save}}.",
+ "account_upgrade_dialog_interval_yearly_discount_save": "hemat {{discount}}%",
+ "account_upgrade_dialog_interval_monthly": "Setiap bulan",
+ "account_basics_tier_interval_monthly": "setiap bulan",
+ "account_basics_tier_interval_yearly": "setiap tahun",
+ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "hemat sampai {{discount}}%",
+ "account_upgrade_dialog_tier_features_no_reservations": "Tidak ada topik yang direservasi",
+ "account_upgrade_dialog_tier_price_per_month": "bulan",
+ "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per bulan. Ditagih setiap bulan.",
+ "account_upgrade_dialog_billing_contact_email": "Untuk pertanyaan penagihan, silakan hubungi kami secara langsung.",
+ "account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke situs web kami."
}
diff --git a/web/public/static/langs/pl.json b/web/public/static/langs/pl.json
index 36ce8690..deccad95 100644
--- a/web/public/static/langs/pl.json
+++ b/web/public/static/langs/pl.json
@@ -235,5 +235,78 @@
"account_usage_title": "Użycie",
"account_usage_of_limit": "z {{limit}}",
"account_usage_unlimited": "Bez limitu",
- "account_usage_limits_reset_daily": "Limity są resetowane codziennie o północy (UTC)"
+ "account_usage_limits_reset_daily": "Limity są resetowane codziennie o północy (UTC)",
+ "account_delete_dialog_button_submit": "Nieodwracalnie usuń konto",
+ "account_upgrade_dialog_tier_features_no_reservations": "Brak rezerwacji tematów",
+ "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na plik",
+ "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} pamięci łącznie",
+ "account_upgrade_dialog_tier_price_per_month": "miesiąc",
+ "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} na rok. Płatne miesięcznie.",
+ "account_upgrade_dialog_billing_contact_email": "W razie pytań dotyczących rozliczeń skontaktuj się z nami bezpośrednio.",
+ "account_upgrade_dialog_billing_contact_website": "W razie pytań dotyczących rozliczeń sprawdź naszą stronę.",
+ "account_upgrade_dialog_button_cancel_subscription": "Anuluj subskrypcję",
+ "account_upgrade_dialog_button_update_subscription": "Zmień subskrypcję",
+ "account_tokens_title": "Tokeny dostępowe",
+ "account_tokens_table_token_header": "Token",
+ "account_tokens_table_label_header": "Etykieta",
+ "account_tokens_table_last_access_header": "Ostatnie użycie",
+ "account_tokens_table_expires_header": "Termin ważności",
+ "account_tokens_table_never_expires": "Bezterminowy",
+ "account_tokens_table_current_session": "Aktualna sesja przeglądarki",
+ "account_tokens_table_copy_to_clipboard": "Kopiuj do schowka",
+ "account_tokens_table_copied_to_clipboard": "Token został skopiowany",
+ "account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
+ "account_tokens_table_create_token_button": "Utwórz token dostępowy",
+ "account_tokens_dialog_label": "Etykieta, np. Powiadomienia Radarr",
+ "account_tokens_dialog_button_update": "Zmień token",
+ "account_basics_tier_interval_monthly": "miesięcznie",
+ "account_basics_tier_interval_yearly": "rocznie",
+ "account_upgrade_dialog_interval_monthly": "Miesięcznie",
+ "account_upgrade_dialog_title": "Zmień plan konta",
+ "account_delete_dialog_description": "Konto, wraz ze wszystkimi związanymi z nim danymi przechowywanymi na serwerze, będzie nieodwracalnie usunięte. Po usunięciu Twoja nazwa użytkownika będzie niedostępna jeszcze przez 7 dni. Jeśli chcesz kontynuować, potwierdź wpisując swoje hasło w polu poniżej.",
+ "account_delete_dialog_billing_warning": "Usunięcie konta powoduje natychmiastowe anulowanie subskrypcji. Nie będziesz już mieć dostępu do strony z rachunkami.",
+ "account_upgrade_dialog_interval_yearly": "Rocznie",
+ "account_upgrade_dialog_interval_yearly_discount_save": "taniej o {{discount}}%",
+ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "nawet {{discount}}% taniej",
+ "account_upgrade_dialog_button_cancel": "Anuluj",
+ "account_tokens_description": "Używaj tokenów do publikowania wiadomości i subskrybowania tematów przez API ntfy, żeby uniknąć konieczności podawania danych do logowania. Szczegóły znajdziesz w dokumentacji.",
+ "account_tokens_dialog_title_create": "Utwórz token dostępowy",
+ "account_tokens_table_last_origin_tooltip": "Z adresu IP {{ip}}, kliknij żeby sprawdzić",
+ "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} płatne jednorazowo. Oszczędzasz {{save}}.",
+ "account_tokens_dialog_title_edit": "Edytuj token dostępowy",
+ "account_tokens_dialog_title_delete": "Usuń token dostępowy",
+ "account_tokens_dialog_button_create": "Utwórz token",
+ "nav_upgrade_banner_label": "Przejdź na ntfy Pro",
+ "nav_upgrade_banner_description": "Rezerwuj tematy, więcej powiadomień i maili oraz większe załączniki",
+ "alert_not_supported_context_description": "Powiadomienia działają tylko przez HTTPS. To jest ograniczenie Notifications API.",
+ "account_basics_tier_canceled_subscription": "Twoja subskrypcja została anulowana i konto zostanie ograniczone do wersji darmowej w dniu {{date}}.",
+ "account_basics_tier_manage_billing_button": "Zarządzaj rachunkami",
+ "account_usage_messages_title": "Wysłane wiadomości",
+ "account_usage_emails_title": "Wysłane maile",
+ "account_basics_tier_title": "Rodzaj konta",
+ "account_basics_tier_description": "Mocarność Twojego konta",
+ "account_basics_tier_admin": "Administrator",
+ "account_basics_tier_admin_suffix_with_tier": "(plan {{tier}})",
+ "account_basics_tier_admin_suffix_no_tier": "(brak planu)",
+ "account_basics_tier_basic": "Podstawowe",
+ "account_basics_tier_free": "Darmowe",
+ "account_basics_tier_upgrade_button": "Przejdź na Pro",
+ "account_basics_tier_change_button": "Zmień",
+ "account_basics_tier_paid_until": "Subskrypcja opłacona do {{date}} i będzie odnowiona automatycznie",
+ "account_basics_tier_payment_overdue": "Minął termin płatności. Zaktualizuj metodę płatności, w przeciwnym razie Twoje konto wkrótce zostanie ograniczone.",
+ "account_usage_reservations_title": "Zarezerwowane tematy",
+ "account_usage_reservations_none": "Brak zarezerwowanych tematów na tym koncie",
+ "account_usage_attachment_storage_title": "Miejsce na załączniki",
+ "account_usage_attachment_storage_description": "{{filesize}} na każdy plik, przechowywane przez {{expiry}}",
+ "account_usage_basis_ip_description": "Statystyki i limity dla tego konta bazują na Twoim adresie IP, więc mogą być współdzielone z innymi użytkownikami. Limity pokazane powyżej to wartości przybliżone bazujące na rzeczywistych limitach.",
+ "account_usage_cannot_create_portal_session": "Nie można otworzyć portalu z rachunkami",
+ "account_delete_title": "Usuń konto",
+ "account_delete_description": "Usuń swoje konto nieodwracalnie",
+ "account_delete_dialog_label": "Hasło",
+ "account_delete_dialog_button_cancel": "Anuluj",
+ "account_upgrade_dialog_button_redirect_signup": "Załóż konto",
+ "account_upgrade_dialog_button_pay_now": "Zapłać i aktywuj subskrypcję",
+ "account_tokens_dialog_button_cancel": "Anuluj",
+ "account_tokens_dialog_expires_label": "Token dostępowy wygasa po",
+ "account_tokens_dialog_expires_unchanged": "Pozostaw termin ważności bez zmian"
}
diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json
index 4a74866f..66136cf7 100644
--- a/web/public/static/langs/tr.json
+++ b/web/public/static/langs/tr.json
@@ -251,7 +251,7 @@
"account_delete_dialog_button_submit": "Hesabı kalıcı olarak sil",
"account_delete_dialog_billing_warning": "Hesabınızı silmek, faturalandırma aboneliğinizi de anında iptal eder. Artık faturalandırma sayfasına erişiminiz olmayacak.",
"account_upgrade_dialog_title": "Hesap seviyesini değiştir",
- "account_upgrade_dialog_proration_info": "Ödeme oranı: Ücretli planlar arasında geçiş yaparken, fiyat farkı bir sonraki faturada tahsil edilecek veya iade edilecektir. Bir sonraki fatura döneminin sonuna kadar başka bir fatura almayacaksınız.",
+ "account_upgrade_dialog_proration_info": "Fiyatlandırma: Ücretli planlar arasında yükseltme yaparken, fiyat farkı hemen tahsil edilecektir. Daha düşük bir seviyeye inildiğinde, bakiye gelecek faturalandırma dönemleri için ödeme yapmak üzere kullanılacaktır.",
"account_upgrade_dialog_reservations_warning_other": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce lütfen en az {{count}} ayırtmayı silin. Ayırtmaları Ayarlar sayfasından kaldırabilirsiniz.",
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} konu ayırtıldı",
"account_upgrade_dialog_tier_features_messages": "{{messages}} günlük mesaj",
@@ -340,5 +340,17 @@
"prefs_reservations_table_everyone_read_only": "Ben yayınlayabilir ve abone olabilirim, herkes abone olabilir",
"prefs_reservations_table_not_subscribed": "Abone olunmadı",
"prefs_reservations_table_everyone_read_write": "Herkes yayınlayabilir ve abone olabilir",
- "reservation_delete_dialog_description": "Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz."
+ "reservation_delete_dialog_description": "Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz.",
+ "account_basics_tier_interval_yearly": "yıllık",
+ "account_upgrade_dialog_tier_features_no_reservations": "Ayırtılan konu yok",
+ "account_upgrade_dialog_tier_price_billed_monthly": "Yıllık {{price}}. Aylık faturalandırılır.",
+ "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} yıllık olarak faturalandırılır. {{save}} tasarruf edin.",
+ "account_upgrade_dialog_interval_yearly": "Yıllık",
+ "account_upgrade_dialog_interval_yearly_discount_save": "%{{discount}} tasarruf edin",
+ "account_upgrade_dialog_tier_price_per_month": "ay",
+ "account_upgrade_dialog_billing_contact_email": "Faturalama ile ilgili sorularınız için lütfen doğrudan bizimle iletişime geçin.",
+ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "%{{discount}} kadar tasarruf edin",
+ "account_upgrade_dialog_interval_monthly": "Aylık",
+ "account_basics_tier_interval_monthly": "aylık",
+ "account_upgrade_dialog_billing_contact_website": "Faturalama ile ilgili sorularınız için lütfen web sitemizi ziyaret edin."
}
diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js
index 8b795377..e86af78a 100644
--- a/web/src/app/Connection.js
+++ b/web/src/app/Connection.js
@@ -1,6 +1,6 @@
import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
-const retryBackoffSeconds = [5, 10, 15, 20, 30];
+const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
/**
* A connection contains a single WebSocket connection for one topic. It handles its connection