Просмотр исходного кода

allow default-token and per-subscription tokens in client.yml

Hunter Kehoe 2 лет назад
Родитель
Сommit
25be5b47e4
11 измененных файлов с 572 добавлено и 65 удалено
  1. 8 4
      client/client.yml
  2. 3 0
      client/config.go
  3. 22 0
      client/config_test.go
  4. 20 17
      cmd/publish.go
  5. 152 1
      cmd/publish_test.go
  6. 35 13
      cmd/subscribe.go
  7. 306 0
      cmd/subscribe_test.go
  8. 17 0
      docs/releases.md
  9. 5 5
      docs/subscribe/cli.md
  10. 4 2
      server/server.go
  11. 0 23
      user/manager_test.go

+ 8 - 4
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

+ 3 - 0
client/config.go

@@ -15,11 +15,13 @@ type Config struct {
 	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       []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"`
 	} `yaml:"subscribe"`
@@ -31,6 +33,7 @@ func NewConfig() *Config {
 		DefaultHost:     DefaultBaseURL,
 		DefaultUser:     "",
 		DefaultPassword: nil,
+		DefaultToken:    "",
 		DefaultCommand:  "",
 		Subscribe:       nil,
 	}

+ 22 - 0
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)
+}

+ 20 - 17
cmd/publish.go

@@ -154,25 +154,28 @@ func execPublish(c *cli.Context) error {
 	}
 	if token != "" {
 		options = append(options, client.WithBearerAuth(token))
-	}
-	if user != "" {
-		var pass string
-		parts := strings.SplitN(user, ":", 2)
-		if len(parts) == 2 {
-			user = parts[0]
-			pass = parts[1]
-		} else {
-			fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
-			p, err := util.ReadPassword(c.App.Reader)
-			if err != nil {
-				return err
+	} else {
+		if user != "" {
+			var pass string
+			parts := strings.SplitN(user, ":", 2)
+			if len(parts) == 2 {
+				user = parts[0]
+				pass = parts[1]
+			} else {
+				fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
+				p, err := util.ReadPassword(c.App.Reader)
+				if err != nil {
+					return err
+				}
+				pass = string(p)
+				fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
 			}
-			pass = string(p)
-			fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
+			options = append(options, client.WithBasicAuth(user, pass))
+		} 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))
 		}
-		options = append(options, client.WithBasicAuth(user, pass))
-	} else if token == "" && conf.DefaultUser != "" && conf.DefaultPassword != nil {
-		options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
 	}
 	if pid > 0 {
 		newMessage, err := waitForProcess(pid)

+ 152 - 1
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())
+}

+ 35 - 13
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)
@@ -175,21 +186,32 @@ 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
+
+		// check for subscription token then subscription user:pass
+		var authSet bool
+		if s.Token != "" {
+			topicOptions = append(topicOptions, client.WithBearerAuth(s.Token))
+			authSet = true
+		} else {
+			if s.User != "" && s.Password != nil {
+				topicOptions = append(topicOptions, client.WithBasicAuth(s.User, *s.Password))
+				authSet = true
+			}
 		}
-		if user != "" && password != nil {
-			topicOptions = append(topicOptions, client.WithBasicAuth(user, *password))
+
+		// if no subscription token nor subscription user:pass, check for default token then default user:pass
+		if !authSet {
+			if conf.DefaultToken != "" {
+				topicOptions = append(topicOptions, client.WithBearerAuth(conf.DefaultToken))
+				authSet = true
+			} else {
+				if conf.DefaultUser != "" && conf.DefaultPassword != nil {
+					topicOptions = append(topicOptions, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
+					authSet = true
+				}
+			}
 		}
+
 		subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
 		if s.Command != "" {
 			cmds[subscriptionID] = s.Command

+ 306 - 0
cmd/subscribe_test.go

@@ -0,0 +1,306 @@
+package cmd
+
+import (
+	"fmt"
+	"github.com/stretchr/testify/require"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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()
+
+	go app.Run([]string{"ntfy", "subscribe", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"})
+	// Sleep to give the app time to subscribe
+	time.Sleep(time.Millisecond * 100)
+
+	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", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
+	require.Error(t, err)
+	require.Equal(t, "cannot set both --user and --token", err.Error())
+}

+ 17 - 0
docs/releases.md

@@ -2,6 +2,23 @@
 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.1.3 (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))
+
+## 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))
+* 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))
+
+**Bug fixes + maintenance:**
+
+* 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))
+
 ## ntfy server v2.1.2
 Released March 4, 2023
 

+ 5 - 5
docs/subscribe/cli.md

@@ -254,13 +254,13 @@ I hope this shows how powerful this command is. Here's a short video that demons
   <figcaption>Execute all the things</figcaption>
 </figure>
 
-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))

+ 4 - 2
server/server.go

@@ -1622,6 +1622,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-db 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),
@@ -1633,13 +1634,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() {

+ 0 - 23
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)