1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 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()
}
|