diff --git a/command/initialize.go b/command/initialize.go index eca19f8..0c3bff2 100644 --- a/command/initialize.go +++ b/command/initialize.go @@ -71,7 +71,7 @@ func inputConfigLocation() string { for { fmt.Println("Where to put the config file?") for i, location := range locations { - fmt.Println(fmt.Sprintf("%d. %s", i+1, location)) + fmt.Printf("%d. %s\n", i+1, location) } value := inputString("Enter a number: ") hr() @@ -215,9 +215,9 @@ func inputDefaultPriority() int { erred("Priority needs to be a number between 0 and 10.") continue } else { + hr() return defaultPriority } - hr() } } @@ -251,7 +251,7 @@ func inputServerURL() *url.URL { }) if err == nil { info := version.(models.VersionInfo) - fmt.Println(fmt.Sprintf("Gotify v%s@%s", info.Version, info.BuildDate)) + fmt.Printf("Gotify v%s@%s\n", info.Version, info.BuildDate) return parsedURL } hr() diff --git a/command/push.go b/command/push.go index 22a9296..9e0cafa 100644 --- a/command/push.go +++ b/command/push.go @@ -4,7 +4,6 @@ import ( "fmt" "net/url" "os" - "strings" "github.com/gotify/cli/v2/config" "github.com/gotify/cli/v2/utils" @@ -31,6 +30,7 @@ func Push() cli.Command { cli.StringFlag{Name: "contentType", Usage: "The content type of the message. See https://gotify.net/docs/msgextras#client-display"}, cli.StringFlag{Name: "clickUrl", Usage: "An URL to open upon clicking the notification. See https://gotify.net/docs/msgextras#client-notification"}, cli.BoolFlag{Name: "disable-unescape-backslash", Usage: "Disable evaluating \\n and \\t (if set, \\n and \\t will be seen as a string)"}, + cli.BoolFlag{Name: "no-split", Usage: "Do not split the message on null character when reading from stdin"}, }, Action: doPush, } @@ -39,10 +39,8 @@ func Push() cli.Command { func doPush(ctx *cli.Context) { conf, confErr := config.ReadConfig(config.GetLocations()) - msgText := readMessage(ctx) - if !ctx.Bool("disable-unescape-backslash") { - msgText = utils.Evaluate(msgText) - } + msgTextChan := make(chan string) + go readMessage(ctx.Args(), os.Stdin, msgTextChan, !ctx.Bool("no-split")) priority := ctx.Int("priority") title := ctx.String("title") @@ -72,36 +70,48 @@ func doPush(ctx *cli.Context) { priority = conf.DefaultPriority } - msg := models.MessageExternal{ - Message: msgText, - Title: title, - Priority: priority, + parsedURL, err := url.Parse(stringURL) + if err != nil { + utils.Exit1With("invalid url", stringURL) + return } - msg.Extras = map[string]interface{}{ - } + parsedExtras := make(map[string]interface{}) if contentType != "" { - msg.Extras["client::display"] = map[string]interface{}{ + parsedExtras["client::display"] = map[string]interface{}{ "contentType": contentType, } } if clickUrl != "" { - msg.Extras["client::notification"] = map[string]interface{}{ + parsedExtras["client::notification"] = map[string]interface{}{ "click": map[string]string{ "url": clickUrl, }, } } - parsedURL, err := url.Parse(stringURL) - if err != nil { - utils.Exit1With("invalid url", stringURL) - return - } + var sent bool + for msgText := range msgTextChan { + if !ctx.Bool("disable-unescape-backslash") { + msgText = utils.Evaluate(msgText) + } + + msg := models.MessageExternal{ + Message: msgText, + Title: title, + Priority: priority, + Extras: parsedExtras, + } - pushMessage(parsedURL, token, msg, quiet) + pushMessage(parsedURL, token, msg, quiet) + + sent = true + } + if !sent { + utils.Exit1With("no message sent! a message must be set, either as argument or via stdin") + } } func pushMessage(parsedURL *url.URL, token string, msg models.MessageExternal, quiet bool) { @@ -119,23 +129,3 @@ func pushMessage(parsedURL *url.URL, token string, msg models.MessageExternal, q utils.Exit1With(err) } } - -func readMessage(ctx *cli.Context) string { - msgArgs := strings.Join(ctx.Args(), " ") - - msgStdin := utils.ReadFrom(os.Stdin) - - if msgArgs == "" && msgStdin == "" { - utils.Exit1With("a message must be set, either as argument or via stdin") - } - - if msgArgs != "" && msgStdin != "" { - utils.Exit1With("a message is set via stdin and arguments, use only one of them") - } - - if msgArgs == "" { - return msgStdin - } else { - return msgArgs - } -} diff --git a/command/read.go b/command/read.go new file mode 100644 index 0000000..b138cb7 --- /dev/null +++ b/command/read.go @@ -0,0 +1,63 @@ +package command + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" + + "github.com/gotify/cli/v2/utils" + "github.com/mattn/go-isatty" +) + +func readMessage(args []string, r io.Reader, output chan<- string, splitOnNull bool) { + defer close(output) + + if len(args) > 0 { + if utils.ProbeStdin(r) { + utils.Exit1With("message is set via arguments and stdin, use only one of them") + } + + output <- strings.Join(args, " ") + return + } + + if isatty.IsTerminal(os.Stdin.Fd()) { + eofKey := "Ctrl+D" + if runtime.GOOS == "windows" { + eofKey = "Ctrl+Z" + } + fmt.Fprintf(os.Stderr, "Enter your message, press Enter and then %s to finish:\n", eofKey) + } + + if splitOnNull { + read := bufio.NewReader(r) + for { + s, err := read.ReadString('\x00') + if err != nil { + if !errors.Is(err, io.EOF) { + utils.Exit1With("read error", err) + } + if len(s) > 0 { + output <- s + } + return + } else { + if len(s) > 1 { + output <- strings.TrimSuffix(s, "\x00") + } + } + } + } else { + bytes, err := io.ReadAll(r) + if err != nil { + utils.Exit1With("cannot read", err) + } + output <- string(bytes) + return + } + +} diff --git a/command/read_test.go b/command/read_test.go new file mode 100644 index 0000000..ea2d733 --- /dev/null +++ b/command/read_test.go @@ -0,0 +1,87 @@ +package command + +import ( + "bufio" + "bytes" + "strings" + "testing" +) + +// Polyfill for slices.Equal for Go 1.20 +func slicesEqual[T comparable](a, b []T) bool { + if len(a) != len(b) { + return false + } + for i, v := range a { + if v != b[i] { + return false + } + } + return true +} + +func readChanAll[T any](c chan T) []T { + var res []T + for s := range c { + res = append(res, s) + } + return res +} + +func TestReadMessage(t *testing.T) { + if bytes.IndexByte([]byte("Hello\x00World"), '\x00') != len("Hello") { + t.Errorf("Expected %v, but got %v", len("Hello"), bytes.IndexByte([]byte("Hello\x00World"), '\x00')) + } + rdr := bufio.NewReader(strings.NewReader("Hello\x00World")) + if s, _ := rdr.ReadString('\x00'); s != "Hello\x00" { + t.Errorf("Expected %x, but got %x", "Hello\x00", s) + } + // Test case 1: message set via arguments + output := make(chan string) + go readMessage([]string{"Hello", "World"}, nil, output, false) + + if res := readChanAll(output); !(slicesEqual(res, []string{"Hello World"})) { + t.Errorf("Expected %v, but got %v", []string{"Hello World"}, res) + } + + // Test case 2: message set via arguments should not split on 'split' character + output = make(chan string) + go readMessage([]string{"Hello\x00World"}, nil, output, true) + + if res := readChanAll(output); !(slicesEqual(res, []string{"Hello\x00World"})) { + t.Errorf("Expected %v, but got %v", []string{"Hello\x00World"}, res) + } + + // Test case 3: message set via stdin + output = make(chan string) + go readMessage([]string{}, strings.NewReader("Hello\x00World"), output, true) + + if res := readChanAll(output); !(slicesEqual(res, []string{"Hello", "World"})) { + t.Errorf("Expected %v, but got %v", []string{"Hello", "World"}, res) + } + + // Test case 4: multiple null bytes should be split as one + output = make(chan string) + go readMessage([]string{}, strings.NewReader("Hello\x00\x00World"), output, true) + + if res := readChanAll(output); !(slicesEqual(res, []string{"Hello", "World"})) { + t.Errorf("Expected %v, but got %v", []string{"Hello", "World"}, res) + } + + // Test case 5: multiple null bytes at the end should be split as one + output = make(chan string) + go readMessage([]string{}, strings.NewReader("Hello\x00\x00"), output, true) + + if res := readChanAll(output); !(slicesEqual(res, []string{"Hello"})) { + t.Errorf("Expected %v, but got %v", []string{"Hello"}, res) + } + + // Test case 6: multiple null bytes at the start should be split as one + output = make(chan string) + go readMessage([]string{}, strings.NewReader("\x00\x00World"), output, true) + + if res := readChanAll(output); !(slicesEqual(res, []string{"World"})) { + t.Errorf("Expected %v, but got %v", []string{"World"}, res) + } + +} diff --git a/command/watch.go b/command/watch.go index f0ee519..6f5c483 100644 --- a/command/watch.go +++ b/command/watch.go @@ -120,18 +120,18 @@ func doWatch(ctx *cli.Context) { case "long": fmt.Fprintf(msgData, "command output for \"%s\" changed:\n\n", cmdStringNotation) fmt.Fprintln(msgData, "== BEGIN OLD OUTPUT ==") - fmt.Fprint(msgData, lastOutput) + fmt.Fprintln(msgData, lastOutput) fmt.Fprintln(msgData, "== END OLD OUTPUT ==") fmt.Fprintln(msgData, "== BEGIN NEW OUTPUT ==") - fmt.Fprint(msgData, output) + fmt.Fprintln(msgData, output) fmt.Fprintln(msgData, "== END NEW OUTPUT ==") case "default": fmt.Fprintf(msgData, "command output for \"%s\" changed:\n\n", cmdStringNotation) fmt.Fprintln(msgData, "== BEGIN NEW OUTPUT ==") - fmt.Fprint(msgData, output) + fmt.Fprintln(msgData, output) fmt.Fprintln(msgData, "== END NEW OUTPUT ==") case "short": - fmt.Fprintf(msgData, output) + fmt.Fprint(msgData, output) } msgString := msgData.String() diff --git a/go.mod b/go.mod index ea7acc4..d63874b 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect diff --git a/go.sum b/go.sum index 6c0ae56..757c564 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -94,6 +96,7 @@ golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= diff --git a/utils/readfromstdin.go b/utils/readfromstdin.go index 53c9b9a..e27008e 100644 --- a/utils/readfromstdin.go +++ b/utils/readfromstdin.go @@ -1,22 +1,23 @@ package utils import ( + "io" "os" - "io/ioutil" ) -func ReadFrom(file *os.File) string { - fi, err := os.Stdin.Stat() - if err != nil { - return "" +func ProbeStdin(file io.Reader) bool { + if file == nil { + return false } - if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() { - return "" + if file, ok := file.(*os.File); ok { + fi, err := file.Stat() + if err != nil { + return false + } + if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() { + return false + } } - bytes, err := ioutil.ReadAll(file) - if err != nil { - return "" - } - return string(bytes) + return true }