From a0550b1d79bbc57f543c2ee6d82d71f410b9dcc5 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Sun, 4 Aug 2024 16:41:02 -0500 Subject: [PATCH 1/3] feat: Automatically split STDIN on null characters on push Signed-off-by: eternal-flame-AD --- command/initialize.go | 6 +-- command/push.go | 86 ++++++++++++++++++++---------------------- command/read.go | 61 ++++++++++++++++++++++++++++++ command/read_test.go | 55 +++++++++++++++++++++++++++ command/watch.go | 8 ++-- utils/readfromstdin.go | 25 ++++++------ 6 files changed, 176 insertions(+), 65 deletions(-) create mode 100644 command/read.go create mode 100644 command/read_test.go 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..ddcdad4 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,9 +39,12 @@ 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) + msgText := make(chan string) + null := '\x00' + if ctx.Bool("no-split") { + go readMessage(ctx.Args(), os.Stdin, msgText, nil) + } else { + go readMessage(ctx.Args(), os.Stdin, msgText, &null) } priority := ctx.Int("priority") @@ -72,36 +75,47 @@ 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{}{ - } + var sent bool + for msgText := range msgText { + if !ctx.Bool("disable-unescape-backslash") { + msgText = utils.Evaluate(msgText) + } - if contentType != "" { - msg.Extras["client::display"] = map[string]interface{}{ - "contentType": contentType, + msg := models.MessageExternal{ + Message: msgText, + Title: title, + Priority: priority, } - } - if clickUrl != "" { - msg.Extras["client::notification"] = map[string]interface{}{ - "click": map[string]string{ - "url": clickUrl, - }, + msg.Extras = map[string]interface{}{} + + if contentType != "" { + msg.Extras["client::display"] = map[string]interface{}{ + "contentType": contentType, + } } - } - parsedURL, err := url.Parse(stringURL) - if err != nil { - utils.Exit1With("invalid url", stringURL) - return - } + if clickUrl != "" { + msg.Extras["client::notification"] = map[string]interface{}{ + "click": map[string]string{ + "url": clickUrl, + }, + } + } + + 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 +133,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..c5b8ca4 --- /dev/null +++ b/command/read.go @@ -0,0 +1,61 @@ +package command + +import ( + "io" + "strings" + + "github.com/gotify/cli/v2/utils" +) + +func readMessage(args []string, r io.Reader, output chan<- string, split *rune) { + msgArgs := strings.Join(args, " ") + + if msgArgs != "" { + if utils.ProbeStdin(r) { + utils.Exit1With("message is set via arguments and stdin, use only one of them") + } + + output <- msgArgs + close(output) + return + } + + var buf strings.Builder + for { + var tmp [256]byte + n, err := r.Read(tmp[:]) + if err != nil { + if err.Error() == "EOF" { + break + } + utils.Exit1With(err) + } + tmpStr := string(tmp[:n]) + if split != nil { + // split the message on the null character + parts := strings.Split(tmpStr, string(*split)) + if len(parts) == 1 { + buf.WriteString(parts[0]) + continue + } + + previous := buf.String() + // fuse previous with parts[0], send parts[1] .. parts[n-2] and set parts[n-1] as new previous + firstMsg := previous + parts[0] + output <- firstMsg + for _, part := range parts[1 : len(parts)-1] { + output <- part + } + buf.Reset() + buf.WriteString(parts[len(parts)-1]) + } else { + buf.WriteString(tmpStr) + } + } + + if buf.Len() > 0 { + output <- buf.String() + } + + close(output) +} diff --git a/command/read_test.go b/command/read_test.go new file mode 100644 index 0000000..7023488 --- /dev/null +++ b/command/read_test.go @@ -0,0 +1,55 @@ +package command + +import ( + "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) { + var split rune = '\x00' + + // Test case 1: message set via arguments + output := make(chan string) + go readMessage([]string{"Hello", "World"}, nil, output, nil) + + 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, &split) + + 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, &split) + + if res := readChanAll(output); !(slicesEqual(res, []string{"Hello", "World"})) { + t.Errorf("Expected %v, but got %v", []string{"Hello", "World"}, res) + } +} diff --git a/command/watch.go b/command/watch.go index f0ee519..e098233 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.Fprintln(msgData, output) } msgString := msgData.String() 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 } From fca4b2cf521dcee9bc4e01fb6643ce99f5832b74 Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Mon, 5 Aug 2024 15:14:40 -0500 Subject: [PATCH 2/3] Apply suggestions Signed-off-by: eternal-flame-AD --- command/push.go | 44 +++++++++++++--------------- command/read.go | 70 ++++++++++++++++++-------------------------- command/read_test.go | 42 ++++++++++++++++++++++---- command/watch.go | 2 +- 4 files changed, 87 insertions(+), 71 deletions(-) diff --git a/command/push.go b/command/push.go index ddcdad4..9e0cafa 100644 --- a/command/push.go +++ b/command/push.go @@ -39,13 +39,8 @@ func Push() cli.Command { func doPush(ctx *cli.Context) { conf, confErr := config.ReadConfig(config.GetLocations()) - msgText := make(chan string) - null := '\x00' - if ctx.Bool("no-split") { - go readMessage(ctx.Args(), os.Stdin, msgText, nil) - } else { - go readMessage(ctx.Args(), os.Stdin, msgText, &null) - } + msgTextChan := make(chan string) + go readMessage(ctx.Args(), os.Stdin, msgTextChan, !ctx.Bool("no-split")) priority := ctx.Int("priority") title := ctx.String("title") @@ -81,8 +76,24 @@ func doPush(ctx *cli.Context) { return } + parsedExtras := make(map[string]interface{}) + + if contentType != "" { + parsedExtras["client::display"] = map[string]interface{}{ + "contentType": contentType, + } + } + + if clickUrl != "" { + parsedExtras["client::notification"] = map[string]interface{}{ + "click": map[string]string{ + "url": clickUrl, + }, + } + } + var sent bool - for msgText := range msgText { + for msgText := range msgTextChan { if !ctx.Bool("disable-unescape-backslash") { msgText = utils.Evaluate(msgText) } @@ -91,22 +102,7 @@ func doPush(ctx *cli.Context) { Message: msgText, Title: title, Priority: priority, - } - - msg.Extras = map[string]interface{}{} - - if contentType != "" { - msg.Extras["client::display"] = map[string]interface{}{ - "contentType": contentType, - } - } - - if clickUrl != "" { - msg.Extras["client::notification"] = map[string]interface{}{ - "click": map[string]string{ - "url": clickUrl, - }, - } + Extras: parsedExtras, } pushMessage(parsedURL, token, msg, quiet) diff --git a/command/read.go b/command/read.go index c5b8ca4..c78091f 100644 --- a/command/read.go +++ b/command/read.go @@ -1,61 +1,49 @@ package command import ( + "bufio" + "errors" "io" "strings" "github.com/gotify/cli/v2/utils" ) -func readMessage(args []string, r io.Reader, output chan<- string, split *rune) { - msgArgs := strings.Join(args, " ") +func readMessage(args []string, r io.Reader, output chan<- string, splitOnNull bool) { + defer close(output) - if msgArgs != "" { + switch { + case len(args) > 0: if utils.ProbeStdin(r) { utils.Exit1With("message is set via arguments and stdin, use only one of them") } - output <- msgArgs - close(output) - return - } - - var buf strings.Builder - for { - var tmp [256]byte - n, err := r.Read(tmp[:]) - if err != nil { - if err.Error() == "EOF" { - break + output <- strings.Join(args, " ") + case 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") + } } - utils.Exit1With(err) } - tmpStr := string(tmp[:n]) - if split != nil { - // split the message on the null character - parts := strings.Split(tmpStr, string(*split)) - if len(parts) == 1 { - buf.WriteString(parts[0]) - continue - } - - previous := buf.String() - // fuse previous with parts[0], send parts[1] .. parts[n-2] and set parts[n-1] as new previous - firstMsg := previous + parts[0] - output <- firstMsg - for _, part := range parts[1 : len(parts)-1] { - output <- part - } - buf.Reset() - buf.WriteString(parts[len(parts)-1]) - } else { - buf.WriteString(tmpStr) + default: + bytes, err := io.ReadAll(r) + if err != nil { + utils.Exit1With("cannot read", err) } + output <- string(bytes) + return } - if buf.Len() > 0 { - output <- buf.String() - } - - close(output) } diff --git a/command/read_test.go b/command/read_test.go index 7023488..ea2d733 100644 --- a/command/read_test.go +++ b/command/read_test.go @@ -1,6 +1,8 @@ package command import ( + "bufio" + "bytes" "strings" "testing" ) @@ -27,11 +29,16 @@ func readChanAll[T any](c chan T) []T { } func TestReadMessage(t *testing.T) { - var split rune = '\x00' - + 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, nil) + 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) @@ -39,7 +46,7 @@ func TestReadMessage(t *testing.T) { // Test case 2: message set via arguments should not split on 'split' character output = make(chan string) - go readMessage([]string{"Hello\x00World"}, nil, output, &split) + 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) @@ -47,9 +54,34 @@ func TestReadMessage(t *testing.T) { // Test case 3: message set via stdin output = make(chan string) - go readMessage([]string{}, strings.NewReader("Hello\x00World"), output, &split) + 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 e098233..6f5c483 100644 --- a/command/watch.go +++ b/command/watch.go @@ -131,7 +131,7 @@ func doWatch(ctx *cli.Context) { fmt.Fprintln(msgData, output) fmt.Fprintln(msgData, "== END NEW OUTPUT ==") case "short": - fmt.Fprintln(msgData, output) + fmt.Fprint(msgData, output) } msgString := msgData.String() From 3b4f0d89200d67a673c6173d0fa756aed80fce1e Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Mon, 5 Aug 2024 15:20:16 -0500 Subject: [PATCH 3/3] Detect if STDIN is terminal and print hint Signed-off-by: eternal-flame-AD --- command/read.go | 22 ++++++++++++++++++---- go.mod | 1 + go.sum | 3 +++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/command/read.go b/command/read.go index c78091f..b138cb7 100644 --- a/command/read.go +++ b/command/read.go @@ -3,23 +3,37 @@ 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) - switch { - case len(args) > 0: + 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, " ") - case splitOnNull: + 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') @@ -37,7 +51,7 @@ func readMessage(args []string, r io.Reader, output chan<- string, splitOnNull b } } } - default: + } else { bytes, err := io.ReadAll(r) if err != nil { utils.Exit1With("cannot read", err) 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=