package notify import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" ) // TypeNtfy is the Type of the ntfy provider. const TypeNtfy = "ntfy" // ntfy's priority scale. Max is the one that overrides the phone's quiet // settings, which is the whole point of paging on critical. const ( ntfyPriorityLow = 2 ntfyPriorityDefault = 3 ntfyPriorityHigh = 4 ntfyPriorityMax = 5 ) // Ntfy publishes to an ntfy server. One server per install, from its // configuration; only the topic is per person. type Ntfy struct { baseURL string token string client *http.Client } // NewNtfy builds the provider. token is an optional bearer token for an // access-controlled server. The client's timeout is what keeps a hung ntfy from // stalling a delivery pass: a page is small and infrequent. func NewNtfy(baseURL, token string) *Ntfy { return &Ntfy{ baseURL: strings.TrimSuffix(baseURL, "/"), token: token, client: &http.Client{Timeout: 10 * time.Second}, } } func (n *Ntfy) Type() string { return TypeNtfy } func (n *Ntfy) Label() string { return "ntfy" } func (n *Ntfy) AddressLabel() string { return "Topic" } func (n *Ntfy) AddressHint() string { return "Subscribe to this topic in the ntfy app. A topic is a shared secret: anyone who knows it can read " + "your pages and publish to it, so pick one that is hard to guess." } // Capabilities: ntfy renders an http action as a button on the notification. func (n *Ntfy) Capabilities() Capabilities { return Capabilities{ActionButtons: true} } func (n *Ntfy) Validate(address string) (string, error) { topic := strings.TrimSpace(address) if topic == "" { return "", errors.New("a topic is required") } if strings.ContainsAny(topic, " \t\r\n/?#") { return "", errors.New("a topic cannot contain spaces, slashes, '?' or '#'") } return topic, nil } // ntfyMessage is ntfy's JSON publish format. Using it rather than the X-Actions // header avoids that header's comma and quote escaping rules, which are easy to // break with a title that happens to contain a comma. type ntfyMessage struct { Topic string `json:"topic"` Title string `json:"title,omitempty"` Message string `json:"message"` Priority int `json:"priority,omitempty"` Tags []string `json:"tags,omitempty"` Click string `json:"click,omitempty"` Actions []ntfyAction `json:"actions,omitempty"` } type ntfyAction struct { Action string `json:"action"` Label string `json:"label"` URL string `json:"url"` Method string `json:"method,omitempty"` Clear bool `json:"clear,omitempty"` } // Send POSTs one message to ntfy. func (n *Ntfy) Send(ctx context.Context, msg Message) error { body, err := json.Marshal(n.encode(msg)) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.baseURL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") if n.token != "" { req.Header.Set("Authorization", "Bearer "+n.token) } resp, err := n.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("ntfy returned %s", resp.Status) } return nil } func (n *Ntfy) encode(msg Message) ntfyMessage { out := ntfyMessage{ Topic: msg.Address, Title: msg.Title, Message: msg.Body, Click: msg.ClickURL, } switch msg.Kind { case KindResolved: out.Priority = ntfyPriorityLow out.Tags = []string{"white_check_mark"} case KindTest: out.Tags = []string{"white_check_mark"} default: out.Priority = ntfyPriority(msg.Level) out.Tags = []string{ntfyTag(msg.Level)} } if msg.AckURL != "" { out.Actions = append(out.Actions, ntfyAction{ Action: "http", Label: "Acknowledge", URL: msg.AckURL, Method: "POST", Clear: true, }) } return out } // ntfyPriority maps a severity level onto ntfy's scale. An unrecognised level // gets the default rather than being silenced. func ntfyPriority(level int) int { switch level { case 4: return ntfyPriorityMax case 3: return ntfyPriorityHigh case 2: return ntfyPriorityDefault case 1: return ntfyPriorityLow default: return ntfyPriorityDefault } } func ntfyTag(level int) string { switch level { case 4: return "rotating_light" case 3: return "red_circle" case 2: return "warning" case 1: return "information_source" default: return "bell" } }