1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

129
pkg/services/zulip/zulip.go Normal file
View file

@ -0,0 +1,129 @@
package zulip
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// contentMaxSize defines the maximum allowed message size in bytes.
const (
contentMaxSize = 10000 // bytes
topicMaxLength = 60 // characters
)
// ErrTopicTooLong indicates the topic exceeds the maximum allowed length.
var (
ErrTopicTooLong = errors.New("topic exceeds max length")
ErrMessageTooLong = errors.New("message exceeds max size")
ErrResponseStatusFailure = errors.New("response status code unexpected")
ErrInvalidHost = errors.New("invalid host format")
)
// hostValidator ensures the host is a valid hostname or domain.
var hostValidator = regexp.MustCompile(
`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`,
)
// Service sends notifications to a pre-configured Zulip channel or user.
type Service struct {
standard.Standard
Config *Config
}
// Send delivers a notification message to Zulip.
func (service *Service) Send(message string, params *types.Params) error {
// Clone the config to avoid modifying the original for this send operation.
config := service.Config.Clone()
if params != nil {
if stream, found := (*params)["stream"]; found {
config.Stream = stream
}
if topic, found := (*params)["topic"]; found {
config.Topic = topic
}
}
topicLength := len([]rune(config.Topic))
if topicLength > topicMaxLength {
return fmt.Errorf("%w: %d characters, got %d", ErrTopicTooLong, topicMaxLength, topicLength)
}
messageSize := len(message)
if messageSize > contentMaxSize {
return fmt.Errorf(
"%w: %d bytes, got %d bytes",
ErrMessageTooLong,
contentMaxSize,
messageSize,
)
}
return service.doSend(config, message)
}
// Initialize configures the service with a URL and logger.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{}
if err := service.Config.setURL(nil, configURL); err != nil {
return err
}
return nil
}
// GetID returns the identifier for this service.
func (service *Service) GetID() string {
return Scheme
}
// doSend sends the notification to Zulip using the configured API URL.
//
//nolint:gosec,noctx // Ignoring G107: Potential HTTP request made with variable url
func (service *Service) doSend(config *Config, message string) error {
apiURL := service.getAPIURL(config)
// Validate the host to mitigate SSRF risks
if !hostValidator.MatchString(config.Host) {
return fmt.Errorf("%w: %q", ErrInvalidHost, config.Host)
}
payload := CreatePayload(config, message)
res, err := http.Post(
apiURL,
"application/x-www-form-urlencoded",
strings.NewReader(payload.Encode()),
)
if err == nil && res.StatusCode != http.StatusOK {
err = fmt.Errorf("%w: %s", ErrResponseStatusFailure, res.Status)
}
defer res.Body.Close()
if err != nil {
return fmt.Errorf("failed to send zulip message: %w", err)
}
return nil
}
// getAPIURL constructs the API URL for Zulip based on the Config.
func (service *Service) getAPIURL(config *Config) string {
return (&url.URL{
User: url.UserPassword(config.BotMail, config.BotKey),
Host: config.Host,
Path: "api/v1/messages",
Scheme: "https",
}).String()
}

View file

@ -0,0 +1,110 @@
package zulip
import (
"errors"
"net/url"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme is the identifying part of this service's configuration URL.
const Scheme = "zulip"
// Static errors for configuration validation.
var (
ErrMissingBotMail = errors.New("bot mail missing from config URL")
ErrMissingAPIKey = errors.New("API key missing from config URL")
ErrMissingHost = errors.New("host missing from config URL")
)
// Config for the zulip service.
type Config struct {
standard.EnumlessConfig
BotMail string `desc:"Bot e-mail address" url:"user"`
BotKey string `desc:"API Key" url:"pass"`
Host string `desc:"API server hostname" url:"host,port"`
Stream string ` description:"Target stream name" key:"stream" optional:""`
Topic string ` key:"topic,title" default:""`
}
// GetURL returns a URL representation of its 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 its field values.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// getURL constructs a URL from the Config's fields using the provided resolver.
func (config *Config) getURL(_ types.ConfigQueryResolver) *url.URL {
query := &url.Values{}
if config.Stream != "" {
query.Set("stream", config.Stream)
}
if config.Topic != "" {
query.Set("topic", config.Topic)
}
return &url.URL{
User: url.UserPassword(config.BotMail, config.BotKey),
Host: config.Host,
RawQuery: query.Encode(),
Scheme: Scheme,
}
}
// setURL updates the Config from a URL using the provided resolver.
func (config *Config) setURL(_ types.ConfigQueryResolver, serviceURL *url.URL) error {
var isSet bool
config.BotMail = serviceURL.User.Username()
config.BotKey, isSet = serviceURL.User.Password()
config.Host = serviceURL.Hostname()
if serviceURL.String() != "zulip://dummy@dummy.com" {
if config.BotMail == "" {
return ErrMissingBotMail
}
if !isSet {
return ErrMissingAPIKey
}
if config.Host == "" {
return ErrMissingHost
}
}
config.Stream = serviceURL.Query().Get("stream")
config.Topic = serviceURL.Query().Get("topic")
return nil
}
// Clone creates a copy of the Config.
func (config *Config) Clone() *Config {
return &Config{
BotMail: config.BotMail,
BotKey: config.BotKey,
Host: config.Host,
Stream: config.Stream,
Topic: config.Topic,
}
}
// CreateConfigFromURL creates a new Config from a URL for use within the zulip service.
func CreateConfigFromURL(serviceURL *url.URL) (*Config, error) {
config := Config{}
err := config.setURL(nil, serviceURL)
return &config, err
}

View file

@ -0,0 +1,15 @@
package zulip
// ErrorMessage for error events within the zulip service.
type ErrorMessage string
const (
// MissingAPIKey from the service URL.
MissingAPIKey ErrorMessage = "missing API key"
// MissingHost from the service URL.
MissingHost ErrorMessage = "missing Zulip host"
// MissingBotMail from the service URL.
MissingBotMail ErrorMessage = "missing Bot mail address"
// TopicTooLong if topic is more than 60 characters.
TopicTooLong ErrorMessage = "topic exceeds max length (%d characters): was %d characters"
)

View file

@ -0,0 +1,19 @@
package zulip
import (
"net/url"
)
// CreatePayload compatible with the zulip api.
func CreatePayload(config *Config, message string) url.Values {
form := url.Values{}
form.Set("type", "stream")
form.Set("to", config.Stream)
form.Set("content", message)
if config.Topic != "" {
form.Set("topic", config.Topic)
}
return form
}

View file

@ -0,0 +1,402 @@
package zulip
import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
func TestZulip(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Zulip Suite")
}
var (
service *Service
envZulipURL *url.URL
)
var _ = ginkgo.BeforeSuite(func() {
service = &Service{}
envZulipURL, _ = url.Parse(os.Getenv("SHOUTRRR_ZULIP_URL"))
})
// Helper function to create Zulip URLs with optional overrides.
func createZulipURL(botMail, botKey, host, stream, topic string) *url.URL {
query := url.Values{}
if stream != "" {
query.Set("stream", stream)
}
if topic != "" {
query.Set("topic", topic)
}
u := &url.URL{
Scheme: "zulip",
User: url.UserPassword(botMail, botKey),
Host: host,
RawQuery: query.Encode(),
}
return u
}
var _ = ginkgo.Describe("the zulip service", func() {
ginkgo.When("running integration tests", func() {
ginkgo.It("should not error out", func() {
if envZulipURL.String() == "" {
return
}
serviceURL, _ := url.Parse(envZulipURL.String())
err := service.Initialize(serviceURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("This is an integration test message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("given a service url with missing parts", func() {
ginkgo.It("should return an error if bot mail is missing", func() {
zulipURL := createZulipURL(
"",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"bar",
)
expectErrorMessageGivenURL("bot mail missing from config URL", zulipURL)
})
ginkgo.It("should return an error if api key is missing", func() {
zulipURL := &url.URL{
Scheme: "zulip",
User: url.User("bot-name@zulipchat.com"),
Host: "example.zulipchat.com",
RawQuery: url.Values{
"stream": []string{"foo"},
"topic": []string{"bar"},
}.Encode(),
}
expectErrorMessageGivenURL("API key missing from config URL", zulipURL)
})
ginkgo.It("should return an error if host is missing", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"",
"foo",
"bar",
)
expectErrorMessageGivenURL("host missing from config URL", zulipURL)
})
})
ginkgo.When("given a valid service url is provided", func() {
ginkgo.It("should not return an error", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"bar",
)
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should not return an error with a different bot key", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"differentkey123456789",
"example.zulipchat.com",
"foo",
"bar",
)
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("sending a message", func() {
ginkgo.It("should error if topic exceeds max length", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"",
)
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
longTopic := strings.Repeat("a", topicMaxLength+1) // 61 chars
params := &types.Params{"topic": longTopic}
err = service.Send("test message", params)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.Equal(
fmt.Sprintf(
"topic exceeds max length: %d characters, got %d",
topicMaxLength,
len([]rune(longTopic)),
),
))
})
ginkgo.It("should error if message exceeds max size", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"bar",
)
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
longMessage := strings.Repeat("a", contentMaxSize+1) // 10001 bytes
err = service.Send(longMessage, nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.Equal(
fmt.Sprintf(
"message exceeds max size: %d bytes, got %d bytes",
contentMaxSize,
len(longMessage),
),
))
})
ginkgo.It("should override stream from params", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"original",
"",
)
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := &types.Params{"stream": "newstream"}
httpmock.Activate()
defer httpmock.DeactivateAndReset()
apiURL := service.getAPIURL(&Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "newstream",
})
httpmock.RegisterResponder(
"POST",
apiURL,
httpmock.NewStringResponder(http.StatusOK, ""),
)
err = service.Send("test message", params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should override topic from params", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"original",
)
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
params := &types.Params{"topic": "newtopic"}
httpmock.Activate()
defer httpmock.DeactivateAndReset()
config := &Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "foo",
Topic: "newtopic",
}
apiURL := service.getAPIURL(config)
httpmock.RegisterResponder(
"POST",
apiURL,
func(req *http.Request) (*http.Response, error) {
gomega.Expect(req.FormValue("topic")).To(gomega.Equal("newtopic"))
return httpmock.NewStringResponse(http.StatusOK, ""), nil
},
)
err = service.Send("test message", params)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should handle HTTP errors", func() {
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"bar",
)
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.Activate()
defer httpmock.DeactivateAndReset()
apiURL := service.getAPIURL(service.Config)
httpmock.RegisterResponder(
"POST",
apiURL,
httpmock.NewStringResponder(http.StatusBadRequest, "Bad Request"),
)
err = service.Send("test message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.ContainSubstring(
"failed to send zulip message: response status code unexpected: 400",
))
})
})
ginkgo.Describe("the zulip config", func() {
ginkgo.When("cloning a config object", func() {
ginkgo.It("the clone should have equal values", func() {
// Covers zulip_config.go:75-84 (Clone equality)
config1 := &Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "foo",
Topic: "bar",
}
config2 := config1.Clone()
gomega.Expect(config1).To(gomega.Equal(config2))
})
ginkgo.It("the clone should not be the same struct", func() {
// Covers zulip_config.go:75-84 (Clone identity)
config1 := &Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "foo",
Topic: "bar",
}
config2 := config1.Clone()
gomega.Expect(config1).NotTo(gomega.BeIdenticalTo(config2))
})
})
ginkgo.When("generating a config object", func() {
ginkgo.It("should generate a correct config object using CreateConfigFromURL", func() {
// Covers zulip_config.go:92-98 (CreateConfigFromURL), zulip_config.go:49-72 (setURL)
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"bar",
)
serviceConfig, err := CreateConfigFromURL(zulipURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
config := &Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "foo",
Topic: "bar",
}
gomega.Expect(serviceConfig).To(gomega.Equal(config))
})
ginkgo.It("should update config correctly using SetURL", func() {
// Covers zulip_config.go:27-29 (SetURL), zulip_config.go:49-72 (setURL)
config := &Config{} // Start with empty config
zulipURL := createZulipURL(
"bot-name@zulipchat.com",
"correcthorsebatterystable",
"example.zulipchat.com",
"foo",
"bar",
)
err := config.SetURL(zulipURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
expected := &Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "foo",
Topic: "bar",
}
gomega.Expect(config).To(gomega.Equal(expected))
})
})
ginkgo.When("given a config object with stream and topic", func() {
ginkgo.It("should build the correct service url", func() {
// Covers zulip_config.go:27-46 (GetURL with Topic)
config := Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "foo",
Topic: "bar",
}
url := config.GetURL()
gomega.Expect(url.String()).
To(gomega.Equal("zulip://bot-name%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo&topic=bar"))
})
})
ginkgo.When("given a config object with stream but without topic", func() {
ginkgo.It("should build the correct service url", func() {
// Covers zulip_config.go:27-46 (GetURL without Topic)
config := Config{
BotMail: "bot-name@zulipchat.com",
BotKey: "correcthorsebatterystable",
Host: "example.zulipchat.com",
Stream: "foo",
}
url := config.GetURL()
gomega.Expect(url.String()).
To(gomega.Equal("zulip://bot-name%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo"))
})
})
})
ginkgo.Describe("the zulip payload", func() {
ginkgo.When("creating a payload with topic", func() {
ginkgo.It("should include all fields", func() {
// Covers zulip_payload.go:7-18 (CreatePayload with Topic)
config := &Config{
Stream: "foo",
Topic: "bar",
}
payload := CreatePayload(config, "test message")
gomega.Expect(payload.Get("type")).To(gomega.Equal("stream"))
gomega.Expect(payload.Get("to")).To(gomega.Equal("foo"))
gomega.Expect(payload.Get("content")).To(gomega.Equal("test message"))
gomega.Expect(payload.Get("topic")).To(gomega.Equal("bar"))
})
})
ginkgo.When("creating a payload without topic", func() {
ginkgo.It("should exclude topic field", func() {
// Covers zulip_payload.go:7-18 (CreatePayload without Topic)
config := &Config{
Stream: "foo",
}
payload := CreatePayload(config, "test message")
gomega.Expect(payload.Get("type")).To(gomega.Equal("stream"))
gomega.Expect(payload.Get("to")).To(gomega.Equal("foo"))
gomega.Expect(payload.Get("content")).To(gomega.Equal("test message"))
gomega.Expect(payload.Get("topic")).To(gomega.Equal(""))
})
})
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("zulip"))
})
})
func expectErrorMessageGivenURL(msg ErrorMessage, zulipURL *url.URL) {
err := service.Initialize(zulipURL, testutils.TestLogger())
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.Error()).To(gomega.Equal(string(msg)))
}