Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/notif/pushover.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ You can send notifications using [Pushover](https://pushover.net/).
recipient: gznej3rKEVAvPUxu9vvNnqpmZpokzF
priority: -2
sound: none
timeout: 10s
templateTitle: "{{ .Entry.Image }} released"
templateBody: |
Docker tag {{ .Entry.Image }} which you subscribed to through {{ .Entry.Provider }} provider has been released.
Expand All @@ -25,6 +26,7 @@ You can send notifications using [Pushover](https://pushover.net/).
| `recipientFile` | | Use content of secret file as User key if `recipient` not defined |
| `priority` | | Priority of the notification |
| `sound` | | Notification sound to be used |
| `timeout` | `10s` | Timeout specifies a time limit for the request to be made |
| `templateTitle`[^1] | See [below](#default-templatetitle) | [Notification template](../faq.md#notification-template) for message title |
| `templateBody`[^1] | See [below](#default-templatebody) | [Notification template](../faq.md#notification-template) for message body |

Expand All @@ -35,6 +37,7 @@ You can send notifications using [Pushover](https://pushover.net/).
* `DIUN_NOTIF_PUSHOVER_RECIPIENTFILE`
* `DIUN_NOTIF_PUSHOVER_PRIORITY`
* `DIUN_NOTIF_PUSHOVER_SOUND`
* `DIUN_NOTIF_PUSHOVER_TIMEOUT`
* `DIUN_NOTIF_PUSHOVER_TEMPLATETITLE`
* `DIUN_NOTIF_PUSHOVER_TEMPLATEBODY`

Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ require (
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df
github.com/go-playground/validator/v10 v10.27.0
github.com/gregdel/pushover v1.3.1
github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b // v1.7.2
github.com/jedib0t/go-pretty/v6 v6.6.8
github.com/matcornic/hermes/v2 v2.1.0
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,6 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gregdel/pushover v1.3.1 h1:4bMLITOZ15+Zpi6qqoGqOPuVHCwSUvMCgVnN5Xhilfo=
github.com/gregdel/pushover v1.3.1/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/hashicorp/cronexpr v1.1.2 h1:wG/ZYIKT+RT3QkOdgYc+xsKWVRgnxJ1OJtjjy84fJ9A=
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ for <code>{{ .Entry.Manifest.Platform }}</code> platform.
Pushover: &model.NotifPushover{
Token: "uQiRzpo4DXghDmr9QzzfQu27cmVRsG",
Recipient: "gznej3rKEVAvPUxu9vvNnqpmZpokzF",
Timeout: utl.NewDuration(10 * time.Second),
TemplateTitle: model.NotifDefaultTemplateTitle,
TemplateBody: model.NotifDefaultTemplateBody,
},
Expand Down
24 changes: 16 additions & 8 deletions internal/model/notif_pushover.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package model

import (
"time"

"github.com/crazy-max/diun/v4/pkg/utl"
)

// NotifPushover holds Pushover notification configuration details
type NotifPushover struct {
Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"`
TokenFile string `yaml:"tokenFile,omitempty" json:"tokenFile,omitempty" validate:"omitempty,file"`
Recipient string `yaml:"recipient,omitempty" json:"recipient,omitempty" validate:"omitempty"`
RecipientFile string `yaml:"recipientFile,omitempty" json:"recipientFile,omitempty" validate:"omitempty,file"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
Sound string `yaml:"sound,omitempty" json:"sound,omitempty" validate:"omitempty"`
TemplateTitle string `yaml:"templateTitle,omitempty" json:"templateTitle,omitempty" validate:"required"`
TemplateBody string `yaml:"templateBody,omitempty" json:"templateBody,omitempty" validate:"required"`
Token string `yaml:"token,omitempty" json:"token,omitempty" validate:"omitempty"`
TokenFile string `yaml:"tokenFile,omitempty" json:"tokenFile,omitempty" validate:"omitempty,file"`
Recipient string `yaml:"recipient,omitempty" json:"recipient,omitempty" validate:"omitempty"`
RecipientFile string `yaml:"recipientFile,omitempty" json:"recipientFile,omitempty" validate:"omitempty,file"`
Priority int `yaml:"priority,omitempty" json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
Sound string `yaml:"sound,omitempty" json:"sound,omitempty" validate:"omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"required"`
TemplateTitle string `yaml:"templateTitle,omitempty" json:"templateTitle,omitempty" validate:"required"`
TemplateBody string `yaml:"templateBody,omitempty" json:"templateBody,omitempty" validate:"required"`
}

// GetDefaults gets the default values
Expand All @@ -21,6 +28,7 @@ func (s *NotifPushover) GetDefaults() *NotifPushover {

// SetDefaults sets the default values
func (s *NotifPushover) SetDefaults() {
s.Timeout = utl.NewDuration(10 * time.Second)
s.TemplateTitle = NotifDefaultTemplateTitle
s.TemplateBody = NotifDefaultTemplateBody
}
99 changes: 86 additions & 13 deletions internal/notif/pushover/client.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
package pushover

import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/crazy-max/diun/v4/internal/model"
"github.com/crazy-max/diun/v4/internal/msg"
"github.com/crazy-max/diun/v4/internal/notif/notifier"
"github.com/crazy-max/diun/v4/pkg/utl"
"github.com/gregdel/pushover"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)

const pushoverAPIURL = "https://api.pushover.net/1/messages.json"

// Client represents an active Pushover notification object
type Client struct {
*notifier.Notifier
Expand Down Expand Up @@ -38,11 +46,15 @@ func (c *Client) Send(entry model.NotifEntry) error {
token, err := utl.GetSecret(c.cfg.Token, c.cfg.TokenFile)
if err != nil {
return errors.Wrap(err, "cannot retrieve token secret for Pushover notifier")
} else if token == "" {
return errors.New("Pushover API token cannot be empty")
}

recipient, err := utl.GetSecret(c.cfg.Recipient, c.cfg.RecipientFile)
if err != nil {
return errors.Wrap(err, "cannot retrieve recipient secret for Pushover notifier")
} else if recipient == "" {
return errors.New("Pushover recipient cannot be empty")
}

message, err := msg.New(msg.Options{
Expand All @@ -60,16 +72,77 @@ func (c *Client) Send(entry model.NotifEntry) error {
return err
}

_, err = pushover.New(token).SendMessage(&pushover.Message{
Title: string(title),
Message: string(body),
Priority: c.cfg.Priority,
Sound: c.cfg.Sound,
URL: c.meta.URL,
URLTitle: c.meta.Name,
Timestamp: time.Now().Unix(),
HTML: true,
}, pushover.NewRecipient(recipient))

return err
cancelCtx, cancel := context.WithCancelCause(context.Background())
timeoutCtx, _ := context.WithTimeoutCause(cancelCtx, *c.cfg.Timeout, errors.WithStack(context.DeadlineExceeded)) //nolint:govet // no need to manually cancel this context as we already rely on parent
defer func() { cancel(errors.WithStack(context.Canceled)) }()

form := url.Values{}
form.Add("token", token)
form.Add("user", recipient)
form.Add("title", string(title))
form.Add("message", string(body))
form.Add("priority", strconv.Itoa(c.cfg.Priority))
if c.cfg.Sound != "" {
form.Add("sound", c.cfg.Sound)
}
if c.meta.URL != "" {
form.Add("url", c.meta.URL)
}
if c.meta.Name != "" {
form.Add("url_title", c.meta.Name)
}
form.Add("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
form.Add("html", "1")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might make sense to log the request body under Debug. (Maybe with token sanitized?)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I think we could


hc := http.Client{}
req, err := http.NewRequestWithContext(timeoutCtx, "POST", pushoverAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", c.meta.UserAgent)

resp, err := hc.Do(req)
if err != nil {
Copy link

@DrEsteban DrEsteban Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crazy-max why not check the status code of the response here? According to the docs, a non-200 response code will not generate an error. If the user entered incorrect credentials and you get a 401/403 here, for example, there's an opportunity to provide a much better error message.

Copy link

@DrEsteban DrEsteban Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You only ever consume/log the StatusCode in case of a failure to parse JSON, which may not fail in the case of non-success responses because the error body might be JSON formatted. This may lead to very confusing behavior for the user to diagnose.

return err
}
defer resp.Body.Close()

if resp.Header != nil {
var appLimit, appRemaining int
var appReset time.Time
if limit := resp.Header.Get("X-Limit-App-Limit"); limit != "" {
if i, err := strconv.Atoi(limit); err == nil {
appLimit = i
}
}
if remaining := resp.Header.Get("X-Limit-App-Remaining"); remaining != "" {
if i, err := strconv.Atoi(remaining); err == nil {
appRemaining = i
}
}
if reset := resp.Header.Get("X-Limit-App-Reset"); reset != "" {
if i, err := strconv.Atoi(reset); err == nil {
appReset = time.Unix(int64(i), 0)
}
}
log.Debug().Msgf("Pushover app limit: %d, remaining: %d, reset: %s", appLimit, appRemaining, appReset)
Copy link

@DrEsteban DrEsteban Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't want to return early with an error here? Don't we already know the POST failed due to rate limiting if we get here? Won't this run the risk of the app assuming the POST was successful and recording it as such in the DB, when in reality it never went through?

I think ideally there'd be some mechanism to automatically retry the request later after appReset has elapsed. (And possibly start queuing other requests since we know they will fail until the limit is renewed.) But at the very least it should return an error so higher level code paths don't assume the post was successful.

Having that many images need an update at the same time, such that the rate limit is exhausted, might be somewhat of an edge case or only a first-run concern... But IMO it just speaks to the reliability & "completeness" of the Pushover integration!

Copy link

@DrEsteban DrEsteban Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah forgive me, I misread this if block. This is just gathering the information, which might be there even on successful calls.

I guess the sort of thing I described above would need to live in an if checking for StatusCode == 429.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes didn't want yet to bail out if rate-limiting detected but we could as follow-up. Just wanted to log it first.

}

var respBody struct {
Status int `json:"status"`
Request string `json:"request"`
Errors []string `json:"errors"`
User string `json:"user"`
Token string `json:"token"`
}

if err = json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
Copy link

@DrEsteban DrEsteban Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest logging the full unparsed response body (e.g. as a string) when debug logging turned on. As well as basic HTTP stuff like status code.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is next line if unmarshal fails:

return errors.Wrapf(err, "cannot decode JSON body response for HTTP %d %s status: %+v", resp.StatusCode, http.StatusText(resp.StatusCode), respBody)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah I meant unconditionally log it! Not just on failure to parse as JSON.

I'm not sure if that's a convention you follow, but I find it useful to have those sorts of things in verbose logs for providing more context when diagnosing issues -- even if the call is successful. That's what the Debug log is for, IMO 🙂

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And actually... On a second look, I'm not sure the existing log statement will work as intended. It's trying to serialize the struct, which won't be populated since JSON deserialization failed. I think you need to read the body as a string and log that instead.

return errors.Wrapf(err, "cannot decode JSON body response for HTTP %d %s status: %+v", resp.StatusCode, http.StatusText(resp.StatusCode), respBody)
}
if respBody.Status != 1 {
return errors.Errorf("Pushover API call failed with status %d: %v", respBody.Status, respBody.Errors)
}

return nil
}

This file was deleted.

21 changes: 0 additions & 21 deletions vendor/github.com/gregdel/pushover/LICENSE

This file was deleted.

134 changes: 0 additions & 134 deletions vendor/github.com/gregdel/pushover/README.md

This file was deleted.

Loading