Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(gotify): handle token ending in / #235

Merged
merged 2 commits into from May 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/testutils/mockclientservice.go
@@ -0,0 +1,8 @@
package testutils

import "net/http"

// MockClientService is used to allow mocking the HTTP client when testing
type MockClientService interface {
GetHTTPClient() *http.Client
}
42 changes: 21 additions & 21 deletions pkg/services/gotify/gotify.go
@@ -1,9 +1,7 @@
package gotify

import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
Expand All @@ -13,14 +11,16 @@ import (
"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/services/standard"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/containrrr/shoutrrr/pkg/util/jsonclient"
)

// Service providing Gotify as a notification service
type Service struct {
standard.Standard
config *Config
pkr format.PropKeyResolver
Client *http.Client
config *Config
pkr format.PropKeyResolver
httpClient *http.Client
client jsonclient.Client
}

// Initialize loads ServiceConfig from configURL and sets logger for this Service
Expand All @@ -32,7 +32,7 @@ func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) e
service.pkr = format.NewPropKeyResolver(service.config)
err := service.config.SetURL(configURL)

service.Client = &http.Client{
service.httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
// If DisableTLS is specified, we might still need to disable TLS verification
Expand All @@ -44,6 +44,7 @@ func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) e
// Set a reasonable timeout to prevent one bad transfer from block all subsequent ones
Timeout: 10 * time.Second,
}
service.client = jsonclient.NewWithHTTPClient(service.httpClient)

return err
}
Expand All @@ -69,11 +70,8 @@ func isTokenValid(token string) bool {

func buildURL(config *Config) (string, error) {
token := config.Token
if len(token) > 0 && token[0] == '/' {
token = token[1:]
}
if !isTokenValid(token) {
return "", fmt.Errorf("invalid gotify token \"%s\"", token)
return "", fmt.Errorf("invalid gotify token %q", token)
}
scheme := "https"
if config.DisableTLS {
Expand All @@ -96,24 +94,26 @@ func (service *Service) Send(message string, params *types.Params) error {
if err != nil {
return err
}
jsonBody, err := json.Marshal(JSON{

request := &messageRequest{
Message: message,
Title: config.Title,
Priority: config.Priority,
})
if err != nil {
return err
}
jsonBuffer := bytes.NewBuffer(jsonBody)
resp, err := service.Client.Post(postURL, "application/json", jsonBuffer)
response := &messageResponse{}
err = service.client.Post(postURL, request, response)
if err != nil {
errorRes := &errorResponse{}
if service.client.ErrorResponse(err, errorRes) {
return errorRes
}
return fmt.Errorf("failed to send notification to Gotify: %s", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Gotify notification returned %d HTTP status code", resp.StatusCode)
}

return nil
}

// GetHTTPClient is only supposed to be used for mocking the httpclient when testing
func (service *Service) GetHTTPClient() *http.Client {
return service.httpClient
}
19 changes: 14 additions & 5 deletions pkg/services/gotify/gotify_config.go
@@ -1,11 +1,12 @@
package gotify

import (
"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/types"
"net/url"
"strings"

"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/types"

"github.com/containrrr/shoutrrr/pkg/services/standard"
)

Expand Down Expand Up @@ -44,11 +45,19 @@ func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {

func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {

tokenIndex := strings.LastIndex(url.Path, "/")
config.Path = url.Path[:tokenIndex]
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 = url.Path[tokenIndex:]
config.Token = path[tokenIndex:]

for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
Expand Down
23 changes: 21 additions & 2 deletions pkg/services/gotify/gotify_json.go
@@ -1,8 +1,27 @@
package gotify

// JSON is the actual payload being sent to the Gotify API
type JSON struct {
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 errorResponse struct {
Name string `json:"error"`
Code uint64 `json:"errorCode"`
Description string `json:"errorDescription"`
}

func (er *errorResponse) Error() string {
return fmt.Sprintf("server respondend with %v (%v): %v", er.Name, er.Code, er.Description)
}
65 changes: 43 additions & 22 deletions pkg/services/gotify/gotify_test.go
@@ -1,12 +1,13 @@
package gotify

import (
"errors"
"github.com/jarcoal/httpmock"
"log"
"net/url"
"testing"

"github.com/containrrr/shoutrrr/internal/testutils"

"github.com/jarcoal/httpmock"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
Expand Down Expand Up @@ -76,36 +77,52 @@ var _ = Describe("the Gotify plugin URL building and token validation functions"
Expect(isTokenValid(token)).To(BeFalse())
})
})
Describe("creating the API URL", func() {
When("the token is invalid", func() {
It("should return an error", func() {
config := Config{
Token: "invalid",
}
_, err := buildURL(&config)
Expect(err).To(HaveOccurred())
})
})
})
Describe("creating a config", func() {
When("parsing the configuration URL", func() {
It("should be identical after de-/serialization (with path)", func() {
testURL := "gotify://my.gotify.tld/gotify/Aaa.bbb.ccc.ddd?title=Test+title"

url, err := url.Parse(testURL)
Expect(err).NotTo(HaveOccurred(), "parsing")

config := &Config{}
err = config.SetURL(url)
Expect(err).NotTo(HaveOccurred(), "verifying")

outputURL := config.GetURL()
Expect(outputURL.String()).To(Equal(testURL))

Expect(config.SetURL(testutils.URLMust(testURL))).To(Succeed())
Expect(config.GetURL().String()).To(Equal(testURL))
})
It("should be identical after de-/serialization (without path)", func() {
testURL := "gotify://my.gotify.tld/Aaa.bbb.ccc.ddd?disabletls=Yes&priority=1&title=Test+title"

url, err := url.Parse(testURL)
Expect(err).NotTo(HaveOccurred(), "parsing")

config := &Config{}
err = config.SetURL(url)
Expect(err).NotTo(HaveOccurred(), "verifying")
Expect(config.SetURL(testutils.URLMust(testURL))).To(Succeed())
Expect(config.GetURL().String()).To(Equal(testURL))

outputURL := config.GetURL()
})
It("should allow slash at the end of the token", func() {
url := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd/")

Expect(outputURL.String()).To(Equal(testURL))
config := &Config{}
Expect(config.SetURL(url)).To(Succeed())
Expect(config.Token).To(Equal("Aaa.bbb.ccc.ddd"))
})
It("should allow slash at the end of the token, with additional path", func() {
url := testutils.URLMust("gotify://my.gotify.tld/path/to/gotify/Aaa.bbb.ccc.ddd/")

config := &Config{}
Expect(config.SetURL(url)).To(Succeed())
Expect(config.Token).To(Equal("Aaa.bbb.ccc.ddd"))
})
It("should not crash on empty token or path slash at the end of the token", func() {
config := &Config{}
Expect(config.SetURL(testutils.URLMust("gotify://my.gotify.tld//"))).To(Succeed())
Expect(config.SetURL(testutils.URLMust("gotify://my.gotify.tld/"))).To(Succeed())
})
})
})
Expand All @@ -119,23 +136,27 @@ var _ = Describe("the Gotify plugin URL building and token validation functions"
It("should not report an error if the server accepts the payload", func() {
serviceURL, _ := url.Parse("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd")
err = service.Initialize(serviceURL, logger)
httpmock.ActivateNonDefault(service.Client)
httpmock.ActivateNonDefault(service.GetHTTPClient())
Expect(err).NotTo(HaveOccurred())

targetURL := "https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd"
httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(200, ""))
httpmock.RegisterResponder("POST", targetURL, testutils.JSONRespondMust(200, messageResponse{}))

err = service.Send("Message", nil)
Expect(err).NotTo(HaveOccurred())
})
It("should not panic if an error occurs when sending the payload", func() {
serviceURL, _ := url.Parse("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd")
err = service.Initialize(serviceURL, logger)
httpmock.ActivateNonDefault(service.Client)
httpmock.ActivateNonDefault(service.GetHTTPClient())
Expect(err).NotTo(HaveOccurred())

targetURL := "https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd"
httpmock.RegisterResponder("POST", targetURL, httpmock.NewErrorResponder(errors.New("dummy error")))
httpmock.RegisterResponder("POST", targetURL, testutils.JSONRespondMust(401, errorResponse{
Name: "Unauthorized",
Code: 401,
Description: "you need to provide a valid access token or user credentials to access this api",
}))

err = service.Send("Message", nil)
Expect(err).To(HaveOccurred())
Expand Down
15 changes: 8 additions & 7 deletions pkg/services/services_test.go
@@ -1,14 +1,15 @@
package services_test

import (
"github.com/containrrr/shoutrrr/pkg/router"
"github.com/containrrr/shoutrrr/pkg/services/gotify"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/jarcoal/httpmock"
"log"
"net/http"
"testing"

"github.com/containrrr/shoutrrr/internal/testutils"
"github.com/containrrr/shoutrrr/pkg/router"
"github.com/containrrr/shoutrrr/pkg/types"
"github.com/jarcoal/httpmock"

. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
Expand Down Expand Up @@ -41,6 +42,7 @@ var serviceURLs = map[string]string{

var serviceResponses = map[string]string{
"pushbullet": `{"created": 0}`,
"gotify": `{"id": 0}`,
}

var logger = log.New(GinkgoWriter, "Test", log.LstdFlags)
Expand Down Expand Up @@ -88,9 +90,8 @@ var _ = Describe("services", func() {
service, err := serviceRouter.Locate(configURL)
Expect(err).NotTo(HaveOccurred())

if key == "gotify" {
gotifyService := service.(*gotify.Service)
httpmock.ActivateNonDefault(gotifyService.Client)
if mockService, ok := service.(testutils.MockClientService); ok {
httpmock.ActivateNonDefault(mockService.GetHTTPClient())
}

err = service.Send("test", (*types.Params)(&map[string]string{
Expand Down
8 changes: 7 additions & 1 deletion pkg/util/jsonclient/jsonclient.go
Expand Up @@ -31,9 +31,15 @@ type client struct {
indent string
}

// NewClient returns a new JSON Client using the default http.Client
func NewClient() Client {
return NewWithHTTPClient(http.DefaultClient)
}

// NewWithHTTPClient returns a new JSON Client using the specified http.Client
func NewWithHTTPClient(httpClient *http.Client) Client {
return &client{
httpClient: http.DefaultClient,
httpClient: httpClient,
headers: http.Header{
"Content-Type": []string{ContentType},
},
Expand Down