diff --git a/docs/services/ntfy.md b/docs/services/ntfy.md new file mode 100644 index 00000000..90149055 --- /dev/null +++ b/docs/services/ntfy.md @@ -0,0 +1,7 @@ +# Ntfy + +Upstream docs: https://docs.ntfy.sh/publish/ + +## URL Format + +--8<-- "docs/services/ntfy/config.md" \ No newline at end of file diff --git a/docs/services/overview.md b/docs/services/overview.md index bfd7d6b0..21414242 100644 --- a/docs/services/overview.md +++ b/docs/services/overview.md @@ -13,6 +13,7 @@ Click on the service for a more thorough explanation. | [Join](./join.md) | *join://shoutrrr:__`api-key`__@join/?devices=__`device1`__[,__`device2`__, ...][&icon=__`icon`__][&title=__`title`__]* | | [Mattermost](./mattermost.md) | *mattermost://[__`username`__@]__`mattermost-host`__/__`token`__[/__`channel`__]* | | [Matrix](./matrix.md) | *matrix://__`username`__:__`password`__@__`host`__:__`port`__/[?rooms=__`!roomID1`__[,__`roomAlias2`__]]* | +| [Ntfy](./ntfy.md) | *ntfy://__`username`__:__`password`__@ntfy.sh/__`topic`__* | | [OpsGenie](./opsgenie.md) | *opsgenie://__`host`__/token?responders=__`responder1`__[,__`responder2`__]* | | [Pushbullet](./pushbullet.md) | *pushbullet://__`api-token`__[/__`device`__/#__`channel`__/__`email`__]* | | [Pushover](./pushover.md) | *pushover://shoutrrr:__`apiToken`__@__`userKey`__/?devices=__`device1`__[,__`device2`__, ...]* | diff --git a/mkdocs.yml b/mkdocs.yml index 9e3f396f..1b4deb90 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - Join: 'services/join.md' - Mattermost: 'services/mattermost.md' - Matrix: 'services/matrix.md' + - Ntfy: 'services/ntfy.md' - OpsGenie: 'services/opsgenie.md' - Pushbullet: 'services/pushbullet.md' - Pushover: 'services/pushover.md' diff --git a/pkg/format/enum_formatter.go b/pkg/format/enum_formatter.go index 4cd61068..aab1920d 100644 --- a/pkg/format/enum_formatter.go +++ b/pkg/format/enum_formatter.go @@ -1,8 +1,9 @@ package format import ( - "github.com/containrrr/shoutrrr/pkg/types" "strings" + + "github.com/containrrr/shoutrrr/pkg/types" ) // EnumInvalid is the constant value that an enum gets assigned when it could not be parsed @@ -10,12 +11,14 @@ const EnumInvalid = -1 // EnumFormatter is the helper methods for enum-like types type EnumFormatter struct { - names []string + names []string + firstOffset int + aliases map[string]int } // Names is the list of the valid Enum string values func (ef EnumFormatter) Names() []string { - return ef.names + return ef.names[ef.firstOffset:] } // Print takes a enum mapped int and returns it's string representation or "Invalid" @@ -34,12 +37,28 @@ func (ef EnumFormatter) Parse(s string) int { return index } } + if index, found := ef.aliases[s]; found { + return index + } return EnumInvalid } // CreateEnumFormatter creates a EnumFormatter struct -func CreateEnumFormatter(names []string) types.EnumFormatter { +func CreateEnumFormatter(names []string, optAliases ...map[string]int) types.EnumFormatter { + aliases := map[string]int{} + if len(optAliases) > 0 { + aliases = optAliases[0] + } + firstOffset := 0 + for i, name := range names { + if name != "" { + firstOffset = i + break + } + } return &EnumFormatter{ names, + firstOffset, + aliases, } } diff --git a/pkg/format/field_info.go b/pkg/format/field_info.go index 6162a2f9..d3720e6c 100644 --- a/pkg/format/field_info.go +++ b/pkg/format/field_info.go @@ -22,6 +22,7 @@ type FieldInfo struct { Title bool Base int Keys []string + ItemSeparator rune } // IsEnum returns whether a EnumFormatter has been assigned to the field and that it is of a suitable type @@ -54,10 +55,11 @@ func getStructFieldInfo(structType r.Type, enums map[string]types.EnumFormatter) } info := FieldInfo{ - Name: fieldDef.Name, - Type: fieldDef.Type, - Required: true, - Title: false, + Name: fieldDef.Name, + Type: fieldDef.Type, + Required: true, + Title: false, + ItemSeparator: ',', } if util.IsNumeric(fieldDef.Type.Kind()) { @@ -94,6 +96,10 @@ func getStructFieldInfo(structType r.Type, enums map[string]types.EnumFormatter) info.Keys = strings.Split(tag, ",") } + if tag, ok := fieldDef.Tag.Lookup("sep"); ok { + info.ItemSeparator = rune(tag[0]) + } + if ef, isEnum := enums[fieldDef.Name]; isEnum { info.EnumFormatter = ef } diff --git a/pkg/format/formatter.go b/pkg/format/formatter.go index 321fa3bb..911f741a 100644 --- a/pkg/format/formatter.go +++ b/pkg/format/formatter.go @@ -3,12 +3,13 @@ package format import ( "errors" "fmt" - "github.com/containrrr/shoutrrr/pkg/types" - "github.com/containrrr/shoutrrr/pkg/util" r "reflect" "strconv" "strings" "unsafe" + + "github.com/containrrr/shoutrrr/pkg/types" + "github.com/containrrr/shoutrrr/pkg/util" ) // GetServiceConfig returns the inner config of a service @@ -139,7 +140,7 @@ func SetConfigField(config r.Value, field FieldInfo, inputValue string) (valid b return false, errors.New("field format is not supported") } - values := strings.Split(inputValue, ",") + values := strings.Split(inputValue, string(field.ItemSeparator)) var value r.Value if elemKind == r.Struct { diff --git a/pkg/format/node.go b/pkg/format/node.go index de0bcd75..f2730c92 100644 --- a/pkg/format/node.go +++ b/pkg/format/node.go @@ -2,12 +2,13 @@ package format import ( "fmt" - "github.com/containrrr/shoutrrr/pkg/types" - "github.com/containrrr/shoutrrr/pkg/util" r "reflect" "sort" "strconv" "strings" + + "github.com/containrrr/shoutrrr/pkg/types" + "github.com/containrrr/shoutrrr/pkg/util" ) // NodeTokenType is used to represent the type of value that a node has for syntax highlighting @@ -268,6 +269,7 @@ func getValueNodeValue(fieldValue r.Value, fieldInfo *FieldInfo) (string, NodeTo } func getContainerValueString(fieldValue r.Value, fieldInfo *FieldInfo) string { + itemSep := fieldInfo.ItemSeparator sliceLen := fieldValue.Len() var mapKeys []r.Value if fieldInfo.Type.Kind() == r.Map { @@ -282,7 +284,7 @@ func getContainerValueString(fieldValue r.Value, fieldInfo *FieldInfo) string { for i := 0; i < sliceLen; i++ { var itemValue r.Value if i > 0 { - sb.WriteRune(',') + sb.WriteRune(itemSep) } if mapKeys != nil { diff --git a/pkg/router/servicemap.go b/pkg/router/servicemap.go index ce7b4147..de9f3338 100644 --- a/pkg/router/servicemap.go +++ b/pkg/router/servicemap.go @@ -11,6 +11,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/services/logger" "github.com/containrrr/shoutrrr/pkg/services/matrix" "github.com/containrrr/shoutrrr/pkg/services/mattermost" + "github.com/containrrr/shoutrrr/pkg/services/ntfy" "github.com/containrrr/shoutrrr/pkg/services/opsgenie" "github.com/containrrr/shoutrrr/pkg/services/pushbullet" "github.com/containrrr/shoutrrr/pkg/services/pushover" @@ -35,6 +36,7 @@ var serviceMap = map[string]func() t.Service{ "logger": func() t.Service { return &logger.Service{} }, "matrix": func() t.Service { return &matrix.Service{} }, "mattermost": func() t.Service { return &mattermost.Service{} }, + "ntfy": func() t.Service { return &ntfy.Service{} }, "opsgenie": func() t.Service { return &opsgenie.Service{} }, "pushbullet": func() t.Service { return &pushbullet.Service{} }, "pushover": func() t.Service { return &pushover.Service{} }, diff --git a/pkg/services/bark/bark_config.go b/pkg/services/bark/bark_config.go index 21a77569..33b303e7 100644 --- a/pkg/services/bark/bark_config.go +++ b/pkg/services/bark/bark_config.go @@ -9,7 +9,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/types" ) -// Config for use within the telegram plugin +// Config for use within the bark service type Config struct { standard.EnumlessConfig Title string `key:"title" default:"" desc:"Notification title, optionally set by the sender"` diff --git a/pkg/services/ntfy/ntfy.go b/pkg/services/ntfy/ntfy.go new file mode 100644 index 00000000..32e8d69d --- /dev/null +++ b/pkg/services/ntfy/ntfy.go @@ -0,0 +1,93 @@ +// Package ntfy implements Ntfy as a shoutrrr service +package ntfy + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/containrrr/shoutrrr/internal/meta" + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/util/jsonclient" + + "github.com/containrrr/shoutrrr/pkg/services/standard" + "github.com/containrrr/shoutrrr/pkg/types" +) + +// Service sends notifications Ntfy +type Service struct { + standard.Standard + config *Config + pkr format.PropKeyResolver +} + +// Send a notification message to Ntfy +func (service *Service) Send(message string, params *types.Params) error { + config := service.config + + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + return err + } + + if err := service.sendAPI(config, message); err != nil { + return fmt.Errorf("failed to send ntfy notification: %w", err) + } + + return nil +} + +// Initialize loads ServiceConfig from configURL and sets logger for this Service +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.Logger.SetLogger(logger) + service.config = &Config{} + service.pkr = format.NewPropKeyResolver(service.config) + + _ = service.pkr.SetDefaultProps(service.config) + + return service.config.setURL(&service.pkr, configURL) + +} + +func (service *Service) sendAPI(config *Config, message string) error { + response := apiResponse{} + request := message + jsonClient := jsonclient.NewClient() + + headers := jsonClient.Headers() + headers.Del("Content-Type") + headers.Set("User-Agent", "shoutrrr/"+meta.Version) + addHeaderIfNotEmpty(&headers, "Title", config.Title) + addHeaderIfNotEmpty(&headers, "Priority", config.Priority.String()) + addHeaderIfNotEmpty(&headers, "Tags", strings.Join(config.Tags, ",")) + addHeaderIfNotEmpty(&headers, "Delay", config.Delay) + addHeaderIfNotEmpty(&headers, "Actions", strings.Join(config.Actions, ";")) + addHeaderIfNotEmpty(&headers, "Click", config.Click) + addHeaderIfNotEmpty(&headers, "Attach", config.Attach) + addHeaderIfNotEmpty(&headers, "X-Icon", config.Icon) + addHeaderIfNotEmpty(&headers, "Filename", config.Filename) + addHeaderIfNotEmpty(&headers, "Email", config.Email) + + if !config.Cache { + headers.Add("Cache", "no") + } + if !config.Firebase { + headers.Add("Firebase", "no") + } + + if err := jsonClient.Post(config.GetAPIURL(), request, &response); err != nil { + if jsonClient.ErrorResponse(err, &response) { + // apiResponse implements Error + return &response + } + return err + } + + return nil +} + +func addHeaderIfNotEmpty(headers *http.Header, key string, value string) { + if value != "" { + headers.Add(key, value) + } +} diff --git a/pkg/services/ntfy/ntfy_config.go b/pkg/services/ntfy/ntfy_config.go new file mode 100644 index 00000000..895c83da --- /dev/null +++ b/pkg/services/ntfy/ntfy_config.go @@ -0,0 +1,108 @@ +package ntfy + +import ( + "net/url" + "strings" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/types" +) + +// Config for use within the ntfy service +type Config struct { + Title string `key:"title" default:"" desc:"Message title"` + Host string `url:"host" default:"ntfy.sh" desc:"Server hostname and port"` + Topic string `url:"path" required:"" desc:"Target topic name"` + Password string `url:"password" optional:"" desc:"Auth password"` + Username string `url:"user" optional:"" desc:"Auth username"` + Scheme string `key:"scheme" default:"https" desc:"Server protocol, http or https"` + Tags []string `key:"tags" optional:"" desc:"List of tags that may or not map to emojis"` + Priority priority `key:"priority" default:"default" desc:"Message priority with 1=min, 3=default and 5=max"` + Actions []string `key:"actions" optional:"" sep:";" desc:"Custom user action buttons for notifications, see https://docs.ntfy.sh/publish/#action-buttons"` + Click string `key:"click" optional:"" desc:"Website opened when notification is clicked"` + Attach string `key:"attach" optional:"" desc:"URL of an attachment, see attach via URL"` + Filename string `key:"filename" optional:"" desc:"File name of the attachment"` + Delay string `key:"delay,at,in" optional:"" desc:"Timestamp or duration for delayed delivery, see https://docs.ntfy.sh/publish/#scheduled-delivery"` + Email string `key:"email" optional:"" desc:"E-mail address for e-mail notifications"` + Icon string `key:"icon" optional:"" desc:"URL to use as notification icon"` + Cache bool `key:"cache" default:"yes" desc:"Cache messages"` + Firebase bool `key:"firebase" default:"yes" desc:"Send to firebase"` +} + +// Enums implements types.ServiceConfig +func (*Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{ + "Priority": Priority.Enum, + } +} + +// GetURL returns a URL representation of it's current field values +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + return config.getURL(&resolver) +} + +// SetURL updates a ServiceConfig from a URL representation of it's field values +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + return config.setURL(&resolver, url) +} + +// GetAPIURL returns the API URL corresponding to the passed endpoint based on the configuration +func (config *Config) GetAPIURL() string { + + path := config.Topic + if !strings.HasPrefix(config.Topic, "/") { + path = "/" + path + } + + var creds *url.Userinfo + if config.Password != "" { + creds = url.UserPassword(config.Username, config.Password) + } + + apiURL := url.URL{ + Scheme: config.Scheme, + Host: config.Host, + Path: path, + User: creds, + } + return apiURL.String() +} + +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: url.UserPassword(config.Username, config.Password), + Host: config.Host, + Scheme: Scheme, + ForceQuery: true, + Path: config.Topic, + RawQuery: format.BuildQuery(resolver), + } + +} + +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + + password, _ := url.User.Password() + config.Password = password + config.Username = url.User.Username() + config.Host = url.Host + config.Topic = strings.TrimPrefix(url.Path, "/") + + // Escape raw `;` in queries + url.RawQuery = strings.ReplaceAll(url.RawQuery, ";", "%3b") + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return err + } + } + + return nil +} + +// Scheme is the identifying part of this service's configuration URL +const ( + Scheme = "ntfy" +) diff --git a/pkg/services/ntfy/ntfy_json.go b/pkg/services/ntfy/ntfy_json.go new file mode 100644 index 00000000..88656f7d --- /dev/null +++ b/pkg/services/ntfy/ntfy_json.go @@ -0,0 +1,17 @@ +package ntfy + +import "fmt" + +type apiResponse struct { + Code int64 `json:"code"` + Message string `json:"error"` + Link string `json:"link"` +} + +func (e *apiResponse) Error() string { + msg := fmt.Sprintf("server response: %v (%v)", e.Message, e.Code) + if e.Link != "" { + return msg + ", see: " + e.Link + } + return msg +} diff --git a/pkg/services/ntfy/ntfy_priority.go b/pkg/services/ntfy/ntfy_priority.go new file mode 100644 index 00000000..e5f0e802 --- /dev/null +++ b/pkg/services/ntfy/ntfy_priority.go @@ -0,0 +1,46 @@ +package ntfy + +import ( + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/types" +) + +type priority int + +type priorityVals struct { + Min priority + Low priority + Default priority + High priority + Max priority + Enum types.EnumFormatter +} + +// Priority ... +var Priority = &priorityVals{ + Min: 1, + Low: 2, + Default: 3, + High: 4, + Max: 5, + Enum: format.CreateEnumFormatter( + []string{ + "", + "Min", + "Low", + "Default", + "High", + "Max", + }, map[string]int{ + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "urgent": 5, + }), +} + +func (p priority) String() string { + return Priority.Enum.Print(int(p)) +} diff --git a/pkg/services/ntfy/ntfy_test.go b/pkg/services/ntfy/ntfy_test.go new file mode 100644 index 00000000..1719433c --- /dev/null +++ b/pkg/services/ntfy/ntfy_test.go @@ -0,0 +1,152 @@ +package ntfy + +import ( + "github.com/containrrr/shoutrrr/internal/testutils" + "github.com/containrrr/shoutrrr/pkg/format" + + "log" + "net/http" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gomegaformat "github.com/onsi/gomega/format" +) + +func TestNtfy(t *testing.T) { + gomegaformat.CharactersAroundMismatchToInclude = 20 + RegisterFailHandler(Fail) + RunSpecs(t, "Shoutrrr Ntfy Suite") +} + +var ( + service *Service = &Service{} + envBarkURL *url.URL + logger *log.Logger = testutils.TestLogger() + _ = BeforeSuite(func() { + envBarkURL, _ = url.Parse(os.Getenv("SHOUTRRR_NTFY_URL")) + }) +) + +var _ = Describe("the ntfy service", func() { + + When("running integration tests", func() { + It("should not error out", func() { + if envBarkURL.String() == "" { + Skip("No integration test ENV URL was set") + return + } + + configURL := testutils.URLMust(envBarkURL.String()) + Expect(service.Initialize(configURL, logger)).To(Succeed()) + Expect(service.Send("This is an integration test message", nil)).To(Succeed()) + }) + }) + + Describe("the config", func() { + When("getting a API URL", func() { + It("should return the expected URL", func() { + + Expect((&Config{ + Host: "host:8080", + Scheme: "http", + Topic: "topic", + }).GetAPIURL()).To(Equal("http://host:8080/topic")) + }) + }) + When("only required fields are set", func() { + It("should set the optional fields to the defaults", func() { + serviceURL := testutils.URLMust("ntfy://hostname/topic") + Expect(service.Initialize(serviceURL, logger)).To(Succeed()) + + Expect(*service.config).To(Equal(Config{ + Host: "hostname", + Topic: "topic", + Scheme: "https", + Tags: []string{""}, + Actions: []string{""}, + Priority: 3, + Firebase: true, + Cache: true, + })) + }) + }) + When("parsing the configuration URL", func() { + It("should be identical after de-/serialization", func() { + testURL := "ntfy://user:pass@example.com:2225/topic?cache=No&click=CLICK&firebase=No&icon=ICON&priority=Max&scheme=http&title=TITLE" + config := &Config{} + pkr := format.NewPropKeyResolver(config) + Expect(config.setURL(&pkr, testutils.URLMust(testURL))).To(Succeed(), "verifying") + Expect(config.GetURL().String()).To(Equal(testURL)) + }) + }) + }) + + When("sending the push payload", func() { + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + It("should not report an error if the server accepts the payload", func() { + serviceURL := testutils.URLMust("ntfy://:devicekey@hostname") + Expect(service.Initialize(serviceURL, logger)).To(Succeed()) + + httpmock.RegisterResponder("POST", service.config.GetAPIURL(), testutils.JSONRespondMust(200, apiResponse{ + Code: http.StatusOK, + Message: "OK", + })) + + Expect(service.Send("Message", nil)).To(Succeed()) + }) + It("should not panic if a server error occurs", func() { + serviceURL := testutils.URLMust("ntfy://:devicekey@hostname") + Expect(service.Initialize(serviceURL, logger)).To(Succeed()) + + httpmock.RegisterResponder("POST", service.config.GetAPIURL(), testutils.JSONRespondMust(500, apiResponse{ + Code: 500, + Message: "someone turned off the internet", + })) + + Expect(service.Send("Message", nil)).To(HaveOccurred()) + }) + It("should not panic if a communication error occurs", func() { + httpmock.DeactivateAndReset() + serviceURL := testutils.URLMust("ntfy://:devicekey@nonresolvablehostname") + Expect(service.Initialize(serviceURL, logger)).To(Succeed()) + Expect(service.Send("Message", nil)).To(HaveOccurred()) + }) + }) + + Describe("the basic service API", func() { + Describe("the service config", func() { + It("should implement basic service config API methods correctly", func() { + testutils.TestConfigGetInvalidQueryValue(&Config{}) + testutils.TestConfigSetInvalidQueryValue(&Config{}, "ntfy://host/topic?foo=bar") + + testutils.TestConfigSetDefaultValues(&Config{}) + + testutils.TestConfigGetEnumsCount(&Config{}, 1) + testutils.TestConfigGetFieldsCount(&Config{}, 15) + }) + }) + Describe("the service instance", func() { + BeforeEach(func() { + httpmock.Activate() + }) + AfterEach(func() { + httpmock.DeactivateAndReset() + }) + It("should implement basic service API methods correctly", func() { + serviceURL := testutils.URLMust("ntfy://:devicekey@hostname") + Expect(service.Initialize(serviceURL, logger)).To(Succeed()) + testutils.TestServiceSetInvalidParamValue(service, "foo", "bar") + }) + }) + }) +}) diff --git a/pkg/util/jsonclient/jsonclient.go b/pkg/util/jsonclient/jsonclient.go index 6d7db2bf..b027ad05 100644 --- a/pkg/util/jsonclient/jsonclient.go +++ b/pkg/util/jsonclient/jsonclient.go @@ -66,9 +66,14 @@ func (c *client) Post(url string, request interface{}, response interface{}) err var err error var body []byte - body, err = json.MarshalIndent(request, "", c.indent) - if err != nil { - return fmt.Errorf("error creating payload: %w", err) + if strReq, ok := request.(string); ok { + // If the request is a string, just pass it through without serializing + body = []byte(strReq) + } else { + body, err = json.MarshalIndent(request, "", c.indent) + if err != nil { + return fmt.Errorf("error creating payload: %w", err) + } } req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))