From 707c58a120c5389889d713a1e0b512f018e22ef8 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 15 Jan 2022 22:33:35 -0500 Subject: [PATCH] Do not print ugly WS error; tests --- client/client.go | 27 ++++++++++----- cmd/app_test.go | 4 +-- cmd/publish_test.go | 36 ++++++++++++++++++++ cmd/serve_test.go | 68 ++++++++++++++++++++++++++++++++++++++ docs/subscribe/api.md | 3 ++ server/errors.go | 49 ++++++++++++++++++++++++++++ server/server.go | 76 +++++++++++++------------------------------ server/server.yml | 3 +- test/server.go | 3 ++ 9 files changed, 204 insertions(+), 65 deletions(-) create mode 100644 cmd/serve_test.go create mode 100644 server/errors.go diff --git a/client/client.go b/client/client.go index b3bf7ab4..c9cc4cd0 100644 --- a/client/client.go +++ b/client/client.go @@ -36,14 +36,16 @@ type Client struct { // Message is a struct that represents a ntfy message type Message struct { // TODO combine with server.message - ID string - Event string - Time int64 - Topic string - Message string - Title string - Priority int - Tags []string + ID string + Event string + Time int64 + Topic string + Message string + Title string + Priority int + Tags []string + Click string + Attachment *Attachment // Additional fields TopicURL string @@ -51,6 +53,15 @@ type Message struct { // TODO combine with server.message Raw string } +type Attachment struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` + Expires int64 `json:"expires,omitempty"` + URL string `json:"url"` + Owner string `json:"-"` // IP address of uploader, used for rate limiting +} + type subscription struct { ID string topicURL string diff --git a/cmd/app_test.go b/cmd/app_test.go index c02ef4f2..9873dd09 100644 --- a/cmd/app_test.go +++ b/cmd/app_test.go @@ -5,8 +5,6 @@ import ( "encoding/json" "github.com/urfave/cli/v2" "heckel.io/ntfy/client" - "io" - "log" "os" "strings" "testing" @@ -15,7 +13,7 @@ import ( // This only contains helpers so far func TestMain(m *testing.M) { - log.SetOutput(io.Discard) + // log.SetOutput(io.Discard) os.Exit(m.Run()) } diff --git a/cmd/publish_test.go b/cmd/publish_test.go index 80d84f8c..23d2d36d 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -34,3 +34,39 @@ func TestCLI_Publish_Subscribe_Poll(t *testing.T) { m = toMessage(t, stdout.String()) require.Equal(t, "some message", m.Message) } + +func TestCLI_Publish_All_The_Things(t *testing.T) { + s, port := test.StartServer(t) + defer test.StopServer(t, s, port) + topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port) + + app, _, stdout, _ := newTestApp() + require.Nil(t, app.Run([]string{ + "ntfy", "publish", + "--title", "this is a title", + "--priority", "high", + "--tags", "tag1,tag2", + // No --delay, --email + "--click", "https://ntfy.sh", + "--attach", "https://f-droid.org/F-Droid.apk", + "--filename", "fdroid.apk", + "--no-cache", + "--no-firebase", + topic, + "some message", + })) + m := toMessage(t, stdout.String()) + require.Equal(t, "message", m.Event) + require.Equal(t, "mytopic", m.Topic) + require.Equal(t, "some message", m.Message) + require.Equal(t, "this is a title", m.Title) + require.Equal(t, 4, m.Priority) + require.Equal(t, []string{"tag1", "tag2"}, m.Tags) + require.Equal(t, "https://ntfy.sh", m.Click) + require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL) + require.Equal(t, "fdroid.apk", m.Attachment.Name) + require.Equal(t, int64(0), m.Attachment.Size) + require.Equal(t, "", m.Attachment.Owner) + require.Equal(t, int64(0), m.Attachment.Expires) + require.Equal(t, "", m.Attachment.Type) +} diff --git a/cmd/serve_test.go b/cmd/serve_test.go new file mode 100644 index 00000000..d49fbbb1 --- /dev/null +++ b/cmd/serve_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "fmt" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + "heckel.io/ntfy/client" + "heckel.io/ntfy/test" + "heckel.io/ntfy/util" + "math/rand" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func init() { + rand.Seed(time.Now().UnixMilli()) +} + +func TestCLI_Serve_Unix_Curl(t *testing.T) { + sockFile := filepath.Join(t.TempDir(), "ntfy.sock") + go func() { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "serve", "--listen-http=-", "--listen-unix=" + sockFile}) + require.Nil(t, err) + }() + for i := 0; i < 40 && !util.FileExists(sockFile); i++ { + time.Sleep(50 * time.Millisecond) + } + require.True(t, util.FileExists(sockFile)) + + cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic") + out, err := cmd.Output() + require.Nil(t, err) + m := toMessage(t, string(out)) + require.Equal(t, "this is a message", m.Message) +} + +func TestCLI_Serve_WebSocket(t *testing.T) { + port := 10000 + rand.Intn(20000) + go func() { + app, _, _, _ := newTestApp() + err := app.Run([]string{"ntfy", "serve", fmt.Sprintf("--listen-http=:%d", port)}) + require.Nil(t, err) + }() + test.WaitForPortUp(t, port) + + ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil) + require.Nil(t, err) + + messageType, data, err := ws.ReadMessage() + require.Nil(t, err) + require.Equal(t, websocket.TextMessage, messageType) + require.Equal(t, "open", toMessage(t, string(data)).Event) + + c := client.New(client.NewConfig()) + _, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message") + require.Nil(t, err) + + messageType, data, err = ws.ReadMessage() + require.Nil(t, err) + require.Equal(t, websocket.TextMessage, messageType) + + m := toMessage(t, string(data)) + require.Equal(t, "my message", m.Message) + require.Equal(t, "mytopic", m.Topic) +} diff --git a/docs/subscribe/api.md b/docs/subscribe/api.md index 0a4d6908..70ef375d 100644 --- a/docs/subscribe/api.md +++ b/docs/subscribe/api.md @@ -184,6 +184,9 @@ format. Keepalive messages are sent as empty lines. fclose($fp); ``` +### Subscribe via WebSockets +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + ## Advanced features ### Poll for messages diff --git a/server/errors.go b/server/errors.go new file mode 100644 index 00000000..ad0d0362 --- /dev/null +++ b/server/errors.go @@ -0,0 +1,49 @@ +package server + +import ( + "encoding/json" + "net/http" +) + +// errHTTP is a generic HTTP error for any non-200 HTTP error +type errHTTP struct { + Code int `json:"code,omitempty"` + HTTPCode int `json:"http"` + Message string `json:"error"` + Link string `json:"link,omitempty"` +} + +func (e errHTTP) Error() string { + return e.Message +} + +func (e errHTTP) JSON() string { + b, _ := json.Marshal(&e) + return string(b) +} + +var ( + errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} + errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} + errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} + errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} + errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} + errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} + errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} + errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} + errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""} + errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} + errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""} + errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} + errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} + errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} + errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} +) diff --git a/server/server.go b/server/server.go index 1168c628..57dd0f38 100644 --- a/server/server.go +++ b/server/server.go @@ -54,23 +54,6 @@ type Server struct { mu sync.Mutex } -// errHTTP is a generic HTTP error for any non-200 HTTP error -type errHTTP struct { - Code int `json:"code,omitempty"` - HTTPCode int `json:"http"` - Message string `json:"error"` - Link string `json:"link,omitempty"` -} - -func (e errHTTP) Error() string { - return e.Message -} - -func (e errHTTP) JSON() string { - b, _ := json.Marshal(&e) - return string(b) -} - type indexPage struct { Topic string CacheDuration time.Duration @@ -128,30 +111,6 @@ var ( //go:embed docs docsStaticFs embed.FS docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} - - errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} - errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} - errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} - errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} - errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} - errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} - errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} - errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""} - errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} - errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""} - errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} - errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} ) const ( @@ -159,10 +118,14 @@ const ( emptyMessageBody = "triggered" // Used if message body is empty defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment fcmMessageLimit = 4000 // see maybeTruncateFCMMessage for details - wsWriteWait = 2 * time.Second - wsBufferSize = 1024 - wsReadLimit = 64 // We only ever receive PINGs - wsPongWait = 15 * time.Second +) + +// WebSocket constants +const ( + wsWriteWait = 2 * time.Second + wsBufferSize = 1024 + wsReadLimit = 64 // We only ever receive PINGs + wsPongWait = 15 * time.Second ) // New instantiates a new Server. It creates the cache and adds a Firebase @@ -371,16 +334,19 @@ func (s *Server) Stop() { func (s *Server) handle(w http.ResponseWriter, r *http.Request) { if err := s.handleInternal(w, r); err != nil { - var e *errHTTP - var ok bool - if e, ok = err.(*errHTTP); !ok { - e = errHTTPInternalError + if websocket.IsWebSocketUpgrade(r) { + log.Printf("[%s] WS %s %s - %s", r.RemoteAddr, r.Method, r.URL.Path, err.Error()) + return // Do not attempt to write to upgraded connection } - log.Printf("[%s] %s - %d - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, e.Code, err.Error()) + httpErr, ok := err.(*errHTTP) + if !ok { + httpErr = errHTTPInternalError + } + log.Printf("[%s] HTTP %s %s - %d - %d - %s", r.RemoteAddr, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error()) w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - w.WriteHeader(e.HTTPCode) - io.WriteString(w, e.JSON()+"\n") + w.WriteHeader(httpErr.HTTPCode) + io.WriteString(w, httpErr.JSON()+"\n") } } @@ -911,7 +877,11 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil { return err } - return g.Wait() + err = g.Wait() + if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + return nil // Normal closures are not errors + } + return err } func parseQueryFilters(r *http.Request) (messageFilter string, titleFilter string, priorityFilter []int, tagsFilter []string, err error) { diff --git a/server/server.yml b/server/server.yml index c65abd7d..736748bb 100644 --- a/server/server.yml +++ b/server/server.yml @@ -6,8 +6,9 @@ # base-url: # Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also -# set "key-file" and "cert-file". Format: : +# set "key-file" and "cert-file". Format: []:, e.g. "1.2.3.4:8080". # +# To listen on all interfaces, you may omit the IP address, e.g. ":443". # To disable HTTP, set "listen-http" to "-". # # listen-http: ":80" diff --git a/test/server.go b/test/server.go index 07382c50..0b9200a6 100644 --- a/test/server.go +++ b/test/server.go @@ -5,6 +5,7 @@ import ( "heckel.io/ntfy/server" "math/rand" "net/http" + "path/filepath" "testing" "time" ) @@ -22,6 +23,8 @@ func StartServer(t *testing.T) (*server.Server, int) { func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) { port := 10000 + rand.Intn(20000) conf.ListenHTTP = fmt.Sprintf(":%d", port) + conf.AttachmentCacheDir = t.TempDir() + conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") s, err := server.New(conf) if err != nil { t.Fatal(err)