Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
148
pkg/services/gotify/gotify.go
Normal file
148
pkg/services/gotify/gotify.go
Normal file
|
@ -0,0 +1,148 @@
|
|||
package gotify
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient"
|
||||
)
|
||||
|
||||
const (
|
||||
// HTTPTimeout defines the HTTP client timeout in seconds.
|
||||
HTTPTimeout = 10
|
||||
TokenLength = 15
|
||||
// TokenChars specifies the valid characters for a Gotify token.
|
||||
TokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"
|
||||
)
|
||||
|
||||
// ErrInvalidToken indicates an invalid Gotify token format or content.
|
||||
var ErrInvalidToken = errors.New("invalid gotify token")
|
||||
|
||||
// Service implements a Gotify notification service.
|
||||
type Service struct {
|
||||
standard.Standard
|
||||
Config *Config
|
||||
pkr format.PropKeyResolver
|
||||
httpClient *http.Client
|
||||
client jsonclient.Client
|
||||
}
|
||||
|
||||
// Initialize configures the service with a URL and logger.
|
||||
//
|
||||
//nolint:gosec
|
||||
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
|
||||
service.SetLogger(logger)
|
||||
service.Config = &Config{
|
||||
Title: "Shoutrrr notification",
|
||||
}
|
||||
service.pkr = format.NewPropKeyResolver(service.Config)
|
||||
|
||||
err := service.Config.SetURL(configURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
// InsecureSkipVerify disables TLS certificate verification when true.
|
||||
// This is set to Config.DisableTLS to support HTTP or self-signed certificate setups,
|
||||
// but it reduces security by allowing potential man-in-the-middle attacks.
|
||||
InsecureSkipVerify: service.Config.DisableTLS,
|
||||
},
|
||||
},
|
||||
Timeout: HTTPTimeout * time.Second,
|
||||
}
|
||||
if service.Config.DisableTLS {
|
||||
service.Log("Warning: TLS verification is disabled, making connections insecure")
|
||||
}
|
||||
|
||||
service.client = jsonclient.NewWithHTTPClient(service.httpClient)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetID returns the identifier for this service.
|
||||
func (service *Service) GetID() string {
|
||||
return Scheme
|
||||
}
|
||||
|
||||
// isTokenValid checks if a Gotify token meets length and character requirements.
|
||||
// Rules are based on Gotify's token validation logic.
|
||||
func isTokenValid(token string) bool {
|
||||
if len(token) != TokenLength || token[0] != 'A' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune(TokenChars, c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// buildURL constructs the Gotify API URL with scheme, host, path, and token.
|
||||
func buildURL(config *Config) (string, error) {
|
||||
token := config.Token
|
||||
if !isTokenValid(token) {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidToken, token)
|
||||
}
|
||||
|
||||
scheme := "https"
|
||||
if config.DisableTLS {
|
||||
scheme = "http" // Use HTTP if TLS is disabled
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s%s/message?token=%s", scheme, config.Host, config.Path, token), nil
|
||||
}
|
||||
|
||||
// Send delivers a notification message to Gotify.
|
||||
func (service *Service) Send(message string, params *types.Params) error {
|
||||
if params == nil {
|
||||
params = &types.Params{}
|
||||
}
|
||||
|
||||
config := service.Config
|
||||
if err := service.pkr.UpdateConfigFromParams(config, params); err != nil {
|
||||
service.Logf("Failed to update params: %v", err)
|
||||
}
|
||||
|
||||
postURL, err := buildURL(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request := &messageRequest{
|
||||
Message: message,
|
||||
Title: config.Title,
|
||||
Priority: config.Priority,
|
||||
}
|
||||
response := &messageResponse{}
|
||||
|
||||
err = service.client.Post(postURL, request, response)
|
||||
if err != nil {
|
||||
errorRes := &responseError{}
|
||||
if service.client.ErrorResponse(err, errorRes) {
|
||||
return errorRes
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to send notification to Gotify: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHTTPClient returns the HTTP client for testing purposes.
|
||||
func (service *Service) GetHTTPClient() *http.Client {
|
||||
return service.httpClient
|
||||
}
|
76
pkg/services/gotify/gotify_config.go
Normal file
76
pkg/services/gotify/gotify_config.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
package gotify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"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 = "gotify"
|
||||
)
|
||||
|
||||
// Config holds settings for the Gotify notification service.
|
||||
type Config struct {
|
||||
standard.EnumlessConfig
|
||||
Token string `desc:"Application token" required:"" url:"path2"`
|
||||
Host string `desc:"Server hostname (and optionally port)" required:"" url:"host,port"`
|
||||
Path string `desc:"Server subpath" url:"path1" optional:""`
|
||||
Priority int ` default:"0" key:"priority"`
|
||||
Title string ` default:"Shoutrrr notification" key:"title"`
|
||||
DisableTLS bool ` default:"No" 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)
|
||||
}
|
||||
|
||||
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
|
||||
return &url.URL{
|
||||
Host: config.Host,
|
||||
Scheme: Scheme,
|
||||
ForceQuery: false,
|
||||
Path: config.Path + config.Token,
|
||||
RawQuery: format.BuildQuery(resolver),
|
||||
}
|
||||
}
|
||||
|
||||
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
|
||||
path := url.Path
|
||||
if len(path) > 0 && path[len(path)-1] == '/' {
|
||||
path = path[:len(path)-1]
|
||||
}
|
||||
|
||||
tokenIndex := strings.LastIndex(path, "/") + 1
|
||||
|
||||
config.Path = path[:tokenIndex]
|
||||
if config.Path == "/" {
|
||||
config.Path = config.Path[1:]
|
||||
}
|
||||
|
||||
config.Host = url.Host
|
||||
config.Token = path[tokenIndex:]
|
||||
|
||||
for key, vals := range url.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
|
||||
}
|
27
pkg/services/gotify/gotify_json.go
Normal file
27
pkg/services/gotify/gotify_json.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package gotify
|
||||
|
||||
import "fmt"
|
||||
|
||||
// messageRequest is the actual payload being sent to the Gotify API.
|
||||
type messageRequest struct {
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
type messageResponse struct {
|
||||
messageRequest
|
||||
ID uint64 `json:"id"`
|
||||
AppID uint64 `json:"appid"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
type responseError struct {
|
||||
Name string `json:"error"`
|
||||
Code uint64 `json:"errorCode"`
|
||||
Description string `json:"errorDescription"`
|
||||
}
|
||||
|
||||
func (er *responseError) Error() string {
|
||||
return fmt.Sprintf("server respondend with %v (%v): %v", er.Name, er.Code, er.Description)
|
||||
}
|
246
pkg/services/gotify/gotify_test.go
Normal file
246
pkg/services/gotify/gotify_test.go
Normal file
|
@ -0,0 +1,246 @@
|
|||
package gotify_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"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/services/gotify"
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||
)
|
||||
|
||||
// Test constants.
|
||||
const (
|
||||
TargetURL = "https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd"
|
||||
)
|
||||
|
||||
// TestGotify runs the Ginkgo test suite for the Gotify package.
|
||||
func TestGotify(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Shoutrrr Gotify Suite")
|
||||
}
|
||||
|
||||
var (
|
||||
service *gotify.Service
|
||||
logger *log.Logger
|
||||
envGotifyURL *url.URL
|
||||
_ = ginkgo.BeforeSuite(func() {
|
||||
service = &gotify.Service{}
|
||||
logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
|
||||
var err error
|
||||
envGotifyURL, err = url.Parse(os.Getenv("SHOUTRRR_GOTIFY_URL"))
|
||||
if err != nil {
|
||||
envGotifyURL = &url.URL{} // Default to empty URL if parsing fails
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
var _ = ginkgo.Describe("the Gotify service", func() {
|
||||
ginkgo.When("running integration tests", func() {
|
||||
ginkgo.It("sends a message successfully with a valid ENV URL", func() {
|
||||
if envGotifyURL.String() == "" {
|
||||
ginkgo.Skip("No integration test ENV URL was set")
|
||||
|
||||
return
|
||||
}
|
||||
serviceURL := testutils.URLMust(envGotifyURL.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.Describe("the service", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
service = &gotify.Service{}
|
||||
service.SetLogger(logger)
|
||||
})
|
||||
ginkgo.It("returns the correct service identifier", func() {
|
||||
gomega.Expect(service.GetID()).To(gomega.Equal("gotify"))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("parsing the configuration URL", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
service = &gotify.Service{}
|
||||
service.SetLogger(logger)
|
||||
})
|
||||
ginkgo.It("builds a valid Gotify URL without path", func() {
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd")
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String()))
|
||||
})
|
||||
ginkgo.When("TLS is disabled", func() {
|
||||
ginkgo.It("uses http scheme", func() {
|
||||
configURL := testutils.URLMust(
|
||||
"gotify://my.gotify.tld/Aaa.bbb.ccc.ddd?disabletls=yes",
|
||||
)
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.DisableTLS).To(gomega.BeTrue())
|
||||
})
|
||||
})
|
||||
ginkgo.When("a custom path is provided", func() {
|
||||
ginkgo.It("includes the path in the URL", func() {
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld/gotify/Aaa.bbb.ccc.ddd")
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String()))
|
||||
})
|
||||
})
|
||||
ginkgo.When("the token has an invalid length", func() {
|
||||
ginkgo.It("reports an error during send", func() {
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld/short") // Length < 15
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
err = service.Send("Message", nil)
|
||||
gomega.Expect(err).To(gomega.MatchError("invalid gotify token: \"short\""))
|
||||
})
|
||||
})
|
||||
ginkgo.When("the token has an invalid prefix", func() {
|
||||
ginkgo.It("reports an error during send", func() {
|
||||
configURL := testutils.URLMust(
|
||||
"gotify://my.gotify.tld/Chwbsdyhwwgarxd",
|
||||
) // Starts with 'C', not 'A'
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
err = service.Send("Message", nil)
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError("invalid gotify token: \"Chwbsdyhwwgarxd\""))
|
||||
})
|
||||
})
|
||||
ginkgo.It("is identical after de-/serialization with path", func() {
|
||||
testURL := "gotify://my.gotify.tld/gotify/Aaa.bbb.ccc.ddd?title=Test+title"
|
||||
serviceURL := testutils.URLMust(testURL)
|
||||
err := service.Initialize(serviceURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL))
|
||||
})
|
||||
ginkgo.It("is identical after de-/serialization without path", func() {
|
||||
testURL := "gotify://my.gotify.tld/Aaa.bbb.ccc.ddd?disabletls=Yes&priority=1&title=Test+title"
|
||||
serviceURL := testutils.URLMust(testURL)
|
||||
err := service.Initialize(serviceURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL))
|
||||
})
|
||||
ginkgo.It("allows slash at the end of the token", func() {
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd/")
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.Token).To(gomega.Equal("Aaa.bbb.ccc.ddd"))
|
||||
})
|
||||
ginkgo.It("allows slash at the end of the token with additional path", func() {
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld/path/to/gotify/Aaa.bbb.ccc.ddd/")
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.Token).To(gomega.Equal("Aaa.bbb.ccc.ddd"))
|
||||
})
|
||||
ginkgo.It("does not crash on empty token or path slash", func() {
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld//")
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(service.Config.Token).To(gomega.Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.When("the token contains invalid characters", func() {
|
||||
ginkgo.It("reports an error during send", func() {
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.dd!")
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
err = service.Send("Message", nil)
|
||||
gomega.Expect(err).To(gomega.MatchError("invalid gotify token: \"Aaa.bbb.ccc.dd!\""))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("sending the payload", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
service = &gotify.Service{}
|
||||
service.SetLogger(logger)
|
||||
configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd")
|
||||
err := service.Initialize(configURL, logger)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
httpmock.ActivateNonDefault(service.GetHTTPClient())
|
||||
httpmock.Activate()
|
||||
})
|
||||
ginkgo.AfterEach(func() {
|
||||
httpmock.DeactivateAndReset()
|
||||
})
|
||||
ginkgo.When("sending via webhook URL", func() {
|
||||
ginkgo.It("does not report an error if the server accepts the payload", func() {
|
||||
httpmock.RegisterResponder(
|
||||
"POST",
|
||||
TargetURL,
|
||||
testutils.JSONRespondMust(200, map[string]any{
|
||||
"id": float64(1),
|
||||
"appid": float64(1),
|
||||
"message": "Message",
|
||||
"title": "Shoutrrr notification",
|
||||
"priority": float64(0),
|
||||
"date": "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
)
|
||||
err := service.Send("Message", nil)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
})
|
||||
ginkgo.It(
|
||||
"reports an error if the server rejects the payload with an error response",
|
||||
func() {
|
||||
httpmock.RegisterResponder(
|
||||
"POST",
|
||||
TargetURL,
|
||||
testutils.JSONRespondMust(401, map[string]any{
|
||||
"error": "Unauthorized",
|
||||
"errorCode": float64(401),
|
||||
"errorDescription": "you need to provide a valid access token or user credentials to access this api",
|
||||
}),
|
||||
)
|
||||
err := service.Send("Message", nil)
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError("server respondend with Unauthorized (401): you need to provide a valid access token or user credentials to access this api"))
|
||||
},
|
||||
)
|
||||
ginkgo.It("reports an error if sending fails with a network error", func() {
|
||||
httpmock.RegisterResponder(
|
||||
"POST",
|
||||
TargetURL,
|
||||
httpmock.NewErrorResponder(errors.New("network failure")),
|
||||
)
|
||||
err := service.Send("Message", nil)
|
||||
gomega.Expect(err).
|
||||
To(gomega.MatchError("failed to send notification to Gotify: sending POST request to \"https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd\": Post \"https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd\": network failure"))
|
||||
})
|
||||
ginkgo.It("logs an error if params update fails", func() {
|
||||
var logBuffer bytes.Buffer
|
||||
service.SetLogger(log.New(&logBuffer, "Test", log.LstdFlags))
|
||||
httpmock.RegisterResponder(
|
||||
"POST",
|
||||
TargetURL,
|
||||
testutils.JSONRespondMust(200, map[string]any{
|
||||
"id": float64(1),
|
||||
"appid": float64(1),
|
||||
"message": "Message",
|
||||
"title": "Shoutrrr notification",
|
||||
"priority": float64(0),
|
||||
"date": "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
)
|
||||
params := types.Params{"priority": "invalid"}
|
||||
err := service.Send("Message", ¶ms)
|
||||
gomega.Expect(err).NotTo(gomega.HaveOccurred())
|
||||
gomega.Expect(logBuffer.String()).
|
||||
To(gomega.ContainSubstring("Failed to update params"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue