File: signal_config.go

package info (click to toggle)
golang-github-nicholas-fedor-shoutrrr 0.12.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 5,680 kB
  • sloc: sh: 74; makefile: 58
file content (197 lines) | stat: -rw-r--r-- 5,944 bytes parent folder | download
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
package signal

import (
	"errors"
	"fmt"
	"net"
	"net/url"
	"regexp"
	"strconv"
	"strings"

	"github.com/nicholas-fedor/shoutrrr/pkg/format"
	"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
	"github.com/nicholas-fedor/shoutrrr/pkg/types"
)

// Scheme identifies this service in configuration URLs.
const (
	Scheme = "signal"
	// minPathParts is the minimum number of path parts required (source + at least one recipient).
	minPathParts = 2
)

// phoneRegex validates phone number format (with or without + prefix).
var phoneRegex = regexp.MustCompile(`^\+?[0-9\s)(+-]+$`)

// groupRegex validates group ID format.
var groupRegex = regexp.MustCompile(`^group\.[a-zA-Z0-9_-]+$`)

// ErrInvalidPhoneNumber indicates an invalid phone number format.
var (
	ErrInvalidPhoneNumber = errors.New("invalid phone number format")
	ErrInvalidGroupID     = errors.New("invalid group ID format")
	ErrNoRecipients       = errors.New("no recipients specified")
	ErrInvalidRecipient   = errors.New("invalid recipient: must be phone number or group ID")
)

// Config holds settings for the Signal notification service.
type Config struct {
	standard.EnumlessConfig
	Host       string   `default:"localhost" desc:"Signal REST API server hostname or IP"      key:"host"`
	Port       int      `default:"8080"      desc:"Signal REST API server port"                key:"port"`
	User       string   `                    desc:"Username for HTTP Basic Auth"               key:"user"`
	Password   string   `                    desc:"Password for HTTP Basic Auth"               key:"password"`
	Token      string   `                    desc:"API token for Bearer authentication"        key:"token,apikey"`
	Source     string   `                    desc:"Source phone number (with country code)"    key:"source"`
	Recipients []string `                    desc:"Recipient phone numbers or group IDs"       key:"recipients,to"`
	DisableTLS bool     `default:"No"        desc:"Disable TLS for Signal REST API connection" key:"disabletls"`
}

// GetURL generates a URL from the current configuration values.
func (config *Config) GetURL() *url.URL {
	resolver := format.NewPropKeyResolver(config)

	return config.getURL(&resolver)
}

// SetURL updates the configuration from a URL representation.
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(resolver types.ConfigQueryResolver) *url.URL {
	recipients := strings.Join(config.Recipients, "/")

	result := &url.URL{
		Scheme:   Scheme,
		Host:     fmt.Sprintf("%s:%d", config.Host, config.Port),
		Path:     fmt.Sprintf("/%s/%s", config.Source, recipients),
		RawQuery: format.BuildQuery(resolver),
	}

	// Add user:password if authentication is configured
	if config.User != "" {
		if config.Password != "" {
			result.User = url.UserPassword(config.User, config.Password)
		} else {
			result.User = url.User(config.User)
		}
	}

	return result
}

// setURL updates the Config from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, serviceURL *url.URL) error {
	// Handle dummy URL used for documentation generation
	if serviceURL.String() == "signal://dummy@dummy.com" {
		config.Host = "localhost"
		config.Port = 8080
		config.Source = "+1234567890"
		config.Recipients = []string{"+0987654321"}
		config.DisableTLS = false

		return nil
	}

	if err := config.parseAuth(serviceURL); err != nil {
		return err
	}

	if err := config.parseHostPort(serviceURL); err != nil {
		return err
	}

	if err := config.parsePath(serviceURL); err != nil {
		return err
	}

	if err := config.parseQuery(resolver, serviceURL); err != nil {
		return err
	}

	return nil
}

// parseAuth extracts user and password from the URL.
func (config *Config) parseAuth(serviceURL *url.URL) error {
	if serviceURL.User != nil {
		config.User = serviceURL.User.Username()
		if password, ok := serviceURL.User.Password(); ok {
			config.Password = password
		}
	}

	return nil
}

// parseHostPort extracts host and port from the URL.
func (config *Config) parseHostPort(serviceURL *url.URL) error {
	host, portStr, err := net.SplitHostPort(serviceURL.Host)
	if err != nil {
		// If no port specified, use default
		host = serviceURL.Host
		portStr = "8080"
	}

	config.Host = host

	if portStr != "" {
		if port, err := strconv.Atoi(portStr); err == nil {
			config.Port = port
		}
	}

	return nil
}

// parsePath extracts source phone number and recipients from the URL path.
func (config *Config) parsePath(serviceURL *url.URL) error {
	pathParts := strings.Split(strings.Trim(serviceURL.Path, "/"), "/")
	if len(pathParts) < minPathParts {
		return ErrNoRecipients
	}

	// First part is source phone number
	source := pathParts[0]
	if !isValidPhoneNumber(source) {
		return fmt.Errorf("%w: %s", ErrInvalidPhoneNumber, source)
	}

	config.Source = source

	// Remaining parts are recipients
	config.Recipients = pathParts[1:]
	for _, recipient := range config.Recipients {
		if !isValidPhoneNumber(recipient) && !isValidGroupID(recipient) {
			return fmt.Errorf("%w: %s", ErrInvalidRecipient, recipient)
		}
	}

	return nil
}

// parseQuery processes query parameters using the resolver.
func (config *Config) parseQuery(resolver types.ConfigQueryResolver, serviceURL *url.URL) error {
	for key, vals := range serviceURL.Query() {
		if err := resolver.Set(key, vals[0]); err != nil {
			return fmt.Errorf("setting config property %q from URL query: %w", key, err)
		}
	}

	return nil
}

// isValidPhoneNumber checks if the string is a valid phone number.
func isValidPhoneNumber(phone string) bool {
	return phoneRegex.MatchString(phone)
}

// isValidGroupID checks if the string is a valid group ID.
func isValidGroupID(groupID string) bool {
	return groupRegex.MatchString(groupID)
}