123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- package cmd
- import (
- "errors"
- "fmt"
- "github.com/urfave/cli/v2"
- "heckel.io/ntfy/v2/client"
- "heckel.io/ntfy/v2/log"
- "heckel.io/ntfy/v2/util"
- "os"
- "os/exec"
- "os/user"
- "path/filepath"
- "sort"
- "strings"
- )
- func init() {
- commands = append(commands, cmdSubscribe)
- }
- const (
- clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
- clientUserConfigFileUnixRelative = "ntfy/client.yml"
- clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
- )
- var flagsSubscribe = append(
- append([]cli.Flag{}, flagsDefault...),
- &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"},
- )
- var cmdSubscribe = &cli.Command{
- Name: "subscribe",
- Aliases: []string{"sub"},
- Usage: "Subscribe to one or more topics on a ntfy server",
- UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
- Action: execSubscribe,
- Category: categoryClient,
- Flags: flagsSubscribe,
- Before: initLogFunc,
- Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
- every arriving message. There are 3 modes in which the command can be run:
- ntfy subscribe TOPIC
- This prints the JSON representation of every incoming message. It is useful when you
- have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,
- this command stays open forever.
- Examples:
- ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
- ntfy sub home.lan/backups # Subscribe to topic on different server
- ntfy sub --poll home.lan/backups # Just query for latest messages and exit
- ntfy sub -u phil:mypass secret # Subscribe with username/password
-
- ntfy subscribe TOPIC COMMAND
- This executes COMMAND for every incoming messages. The message fields are passed to the
- command as environment variables:
- Variable Aliases Description
- --------------- --------------------- -----------------------------------
- $NTFY_ID $id Unique message ID
- $NTFY_TIME $time Unix timestamp of the message delivery
- $NTFY_TOPIC $topic Topic name
- $NTFY_MESSAGE $message, $m Message body
- $NTFY_TITLE $title, $t Message title
- $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
- $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
- $NTFY_RAW $raw Raw JSON message
- Examples:
- ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
- ntfy sub topic1 myscript.sh # Execute script for incoming messages
- ntfy subscribe --from-config
- Service mode (used in ntfy-client.service). This reads the config file and sets up
- subscriptions for every topic in the "subscribe:" block (see config file).
- Examples:
- ntfy sub --from-config # Read topics from config file
- ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file
- ` + clientCommandDescriptionSuffix,
- }
- func execSubscribe(c *cli.Context) error {
- // Read config and options
- conf, err := loadConfig(c)
- if err != nil {
- return err
- }
- 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
- }
- var options []client.SubscribeOption
- if since != "" {
- options = append(options, client.WithSince(since))
- }
- if token != "" {
- options = append(options, client.WithBearerAuth(token))
- } 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))
- }
- 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))
- }
- if scheduled {
- options = append(options, client.WithScheduled())
- }
- if topic == "" && len(conf.Subscribe) == 0 {
- return errors.New("must specify topic, type 'ntfy subscribe --help' for help")
- }
- // Execute poll or subscribe
- if poll {
- return doPoll(c, cl, conf, topic, command, options...)
- }
- return doSubscribe(c, cl, conf, topic, command, options...)
- }
- 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
- }
- }
- if topic != "" {
- if err := doPollSingle(c, cl, topic, command, options...); err != nil {
- return err
- }
- }
- return nil
- }
- func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error {
- messages, err := cl.Poll(topic, options...)
- if err != nil {
- return err
- }
- for _, m := range messages {
- printMessageOrRunCommand(c, m, command)
- }
- return nil
- }
- func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
- cmds := make(map[string]string) // Subscription ID -> command
- for _, s := range conf.Subscribe { // May be nil
- topicOptions := append(make([]client.SubscribeOption, 0), options...)
- for filter, value := range s.If {
- topicOptions = append(topicOptions, client.WithFilter(filter, value))
- }
- if auth := maybeAddAuthHeader(s, conf); auth != nil {
- topicOptions = append(topicOptions, auth)
- }
- subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
- if err != nil {
- return err
- }
- if s.Command != "" {
- cmds[subscriptionID] = s.Command
- } else if conf.DefaultCommand != "" {
- cmds[subscriptionID] = conf.DefaultCommand
- } else {
- cmds[subscriptionID] = ""
- }
- }
- if topic != "" {
- subscriptionID, err := cl.Subscribe(topic, options...)
- if err != nil {
- return err
- }
- cmds[subscriptionID] = command
- }
- for m := range cl.Messages {
- cmd, ok := cmds[m.SubscriptionID]
- if !ok {
- continue
- }
- log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw)
- printMessageOrRunCommand(c, m, cmd)
- }
- return nil
- }
- func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
- // if an explicit empty token or empty user:pass is given, exit without auth
- if (s.Token != nil && *s.Token == "") || (s.User != nil && *s.User == "" && s.Password != nil && *s.Password == "") {
- return client.WithEmptyAuth()
- }
- // check for subscription token then subscription user:pass
- if s.Token != nil && *s.Token != "" {
- return client.WithBearerAuth(*s.Token)
- }
- if s.User != nil && *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)
- } else {
- log.Debug("%s Printing raw message", logMessagePrefix(m))
- fmt.Fprintln(c.App.Writer, m.Raw)
- }
- }
- func runCommand(c *cli.Context, command string, m *client.Message) {
- if err := runCommandInternal(c, command, m); err != nil {
- log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error())
- }
- }
- func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
- scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
- log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile)
- script = scriptHeader + script
- if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
- return err
- }
- defer os.Remove(scriptFile)
- log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile)
- cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
- cmd.Stdin = c.App.Reader
- cmd.Stdout = c.App.Writer
- cmd.Stderr = c.App.ErrWriter
- cmd.Env = envVars(m)
- return cmd.Run()
- }
- func envVars(m *client.Message) []string {
- env := make([]string, 0)
- env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
- env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
- env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
- env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...)
- env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...)
- env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
- env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
- env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
- sort.Strings(env)
- if log.IsTrace() {
- log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n"))
- }
- return append(os.Environ(), env...)
- }
- func envVar(value string, vars ...string) []string {
- env := make([]string, 0)
- for _, v := range vars {
- env = append(env, fmt.Sprintf("%s=%s", v, value))
- }
- return env
- }
- func loadConfig(c *cli.Context) (*client.Config, error) {
- filename := c.String("config")
- if filename != "" {
- return client.LoadConfig(filename)
- }
- configFile, err := defaultClientConfigFile()
- if err != nil {
- log.Warn("Could not determine default client config file: %s", err.Error())
- } else {
- if s, _ := os.Stat(configFile); s != nil {
- return client.LoadConfig(configFile)
- }
- log.Debug("Config file %s not found", configFile)
- }
- log.Debug("Loading default config")
- return client.NewConfig(), nil
- }
- //lint:ignore U1000 Conditionally used in different builds
- func defaultClientConfigFileUnix() (string, error) {
- u, err := user.Current()
- if err != nil {
- return "", fmt.Errorf("could not determine current user: %w", err)
- }
- configFile := clientRootConfigFileUnixAbsolute
- if u.Uid != "0" {
- homeDir, err := os.UserConfigDir()
- if err != nil {
- return "", fmt.Errorf("could not determine user config dir: %w", err)
- }
- return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
- }
- return configFile, nil
- }
- //lint:ignore U1000 Conditionally used in different builds
- func defaultClientConfigFileWindows() (string, error) {
- homeDir, err := os.UserConfigDir()
- if err != nil {
- return "", fmt.Errorf("could not determine user config dir: %w", err)
- }
- return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
- }
- func logMessagePrefix(m *client.Message) string {
- return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
- }
|