Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Automatically split STDIN on null characters on push #70

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions command/initialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func inputConfigLocation() string {
for {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would I use this feature to simplify the journalctl call in #69?

I'm also not 100% sure about the actual use-case of feature, is there something not possible with the current cli feature set and xargs? I don't find the argument that we don't have to use xargs very strong for implementing this inside gotify/cli.

I think if someone doesn't have access to xargs then they probably don't use gotify/cli and instead use some "raw" http call.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would I use this feature to simplify the journalctl call in #69?

journalctl -f -u sshd | awk 'NR % 5 == 0 { print "\0"} { print }' | gotify push

is there something not possible with the current cli feature set and xargs

makes sense, maybe I will just cherry pick the formatting bugs to a different PR and do that

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()
Expand Down Expand Up @@ -215,9 +215,9 @@ func inputDefaultPriority() int {
erred("Priority needs to be a number between 0 and 10.")
continue
} else {
hr()
return defaultPriority
}
hr()
}
}

Expand Down Expand Up @@ -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()
Expand Down
68 changes: 29 additions & 39 deletions command/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"net/url"
"os"
"strings"

"github.com/gotify/cli/v2/config"
"github.com/gotify/cli/v2/utils"
Expand All @@ -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"},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we invert this, so we don't changing existing behavior (reading stdin completely and pushing one message).

},
Action: doPush,
}
Expand All @@ -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")
Expand Down Expand Up @@ -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) {
Expand All @@ -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")
jmattheis marked this conversation as resolved.
Show resolved Hide resolved
}

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
}
}
63 changes: 63 additions & 0 deletions command/read.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be simplified, if we always trim the suffix:

s, err := read.ReadString('\x00')

s = strings.TrimSuffix(s, "\x00")
if len(s) > 0 {
	output <- s
}
if errors.Is(err, io.EOF) {
	return
}
if err != nil {
	utils.Exit1With("read error", err)
}

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
}

}
87 changes: 87 additions & 0 deletions command/read_test.go
Original file line number Diff line number Diff line change
@@ -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)
}

}
8 changes: 4 additions & 4 deletions command/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
25 changes: 13 additions & 12 deletions utils/readfromstdin.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading