File: zulip.go

package info (click to toggle)
golang-github-nicholas-fedor-shoutrrr 0.10.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky
  • size: 4,432 kB
  • sloc: sh: 74; makefile: 5
file content (129 lines) | stat: -rw-r--r-- 3,359 bytes parent folder | download | duplicates (2)
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()
}