diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 92816e6..e5e989b 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 be13b96..7341add 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 544857c..372a87c 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 d3ba272..1b81b80 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 b2efc1d..d4337d4 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 0a71c3b..f22e6b2 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 21578d3..0179f9f 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 6fe2d00..a254f47 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 bbc6fb3..3b4b447 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 0000000..a22b0c9 --- /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 0000000..cdb31a5 --- /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 498a2ff..1640570 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 231336d..8561ef9 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 612ad77..507e6e3 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 0329b35..3104da1 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 0000000..f8894ba 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 0000000..6068138 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 0000000..29342a8 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 0000000..771fbec 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 0000000..e1b7a79 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 0000000..020729e 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 f1f9e76..59cfc8e 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 e3a0d50..66fc4c8 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 9c2212a..17b0fc1 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 7529022..dd161e4 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 389601b..8454869 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 032ec6f..fdda5d9 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 f242af7..cd2e103 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 d14bad0..4245d0f 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 5565042..0000000 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 c9eb5ca..0000000 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 a5d98fc..0000000 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 86b3863..0000000 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 8366379..d663468 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 11987f8..5f41735 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 93e9abb..032e8b7 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 2f871f5..276e442 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 cf7e23a..f6a7361 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 bb1014f..b19df52 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 c3b79b0..6be8c9f 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 36ce869..deccad9 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 4a74866..66136cf 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 8b79537..e86af78 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