internal/push/apns.go

f327db6192d9a0877606a40385b24c0cda29fd2d
gitbay/internal/push/apns.go history · blame · raw

176 lines · 5512 bytes

  1package push
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto/ecdsa"
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"log/slog"
 11	"net"
 12	"net/http"
 13	"os"
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"gitbay.org/gitbay/internal/config"
 19)
 20
 21// result is what one send means for the queue row.
 22type result int
 23
 24const (
 25	resultSent  result = iota // delivered
 26	resultRetry               // transient; back off and try again
 27	resultReap                // Apple says the token is dead; drop the device
 28	resultDead                // permanent for this payload; dead-letter it
 29)
 30
 31// maxBodyBytes keeps an alert inside APNs' 4KB payload limit with room
 32// for the rest of the JSON. A summary longer than this is cut rather
 33// than rejected.
 34const maxBodyBytes = 3000
 35
 36type Client struct {
 37	http   *http.Client
 38	tokens *tokenSource
 39	key    *ecdsa.PrivateKey
 40	host   string
 41	scheme string
 42	topic  string
 43}
 44
 45func NewClient(cfg config.Push) (*Client, error) {
 46	c := &Client{
 47		// stdlib negotiates HTTP/2 over ALPN, which is what APNs
 48		// requires; no explicit http2 transport is needed.
 49		http:   &http.Client{Timeout: 30 * time.Second},
 50		host:   cfg.Host(),
 51		scheme: apnsScheme(),
 52		topic:  cfg.Topic,
 53	}
 54	if cfg.KeyFile != "" {
 55		key, err := config.LoadAPNSKey(cfg.KeyFile)
 56		if err != nil {
 57			return nil, err
 58		}
 59		c.key = key
 60		c.tokens = newTokenSource(key, cfg.KeyID, cfg.TeamID)
 61	}
 62	return c, nil
 63}
 64
 65// apnsScheme is https for the real Apple hosts. GITBAY_APNS_HOST redirects
 66// the endpoint for tests (config.Push.Host), and the fake it points at
 67// speaks plain HTTP/1.1 rather than negotiating TLS, so the same override
 68// has to drop the scheme too, or every request fails with "server gave
 69// HTTP response to HTTPS client" instead of reaching the fake at all.
 70//
 71// The drop only applies to a host on this machine. The provider token is
 72// a bearer credential, valid for an hour and good for any device under
 73// the topic; putting it on the wire in cleartext to somewhere else is not
 74// a thing the test override should be able to arrange. Every fake in the
 75// tree is an httptest server, which always binds loopback, so nothing
 76// loses anything by the restriction. Either way the decision is logged,
 77// so an operator who set the variable learns what it did.
 78func apnsScheme() string {
 79	h := os.Getenv("GITBAY_APNS_HOST")
 80	if h == "" {
 81		return "https"
 82	}
 83	if !loopbackHost(h) {
 84		slog.Warn("push: GITBAY_APNS_HOST is not on this machine, still sending over HTTPS; the provider token is a bearer credential and does not travel in cleartext", "host", h)
 85		return "https"
 86	}
 87	slog.Warn("push: GITBAY_APNS_HOST is set, sending to it over plain HTTP instead of APNs", "host", h)
 88	return "http"
 89}
 90
 91// loopbackHost reports whether a host:port names this machine. The port
 92// is optional: config.Push.Host returns a bare hostname for the real
 93// endpoints, and the override may or may not carry one.
 94func loopbackHost(hostport string) bool {
 95	host := hostport
 96	if h, _, err := net.SplitHostPort(hostport); err == nil {
 97		host = h
 98	}
 99	host = strings.Trim(host, "[]")
100	if host == "localhost" {
101		return true
102	}
103	ip := net.ParseIP(host)
104	return ip != nil && ip.IsLoopback()
105}
106
107// Send delivers one alert. The returned duration is the server's
108// Retry-After when it gave one, zero otherwise.
109func (c *Client) Send(ctx context.Context, token, title, body, path string) (result, time.Duration, error) {
110	if len(body) > maxBodyBytes {
111		// A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji,
112		// accents, non-Latin usernames). ToValidUTF8 drops the
113		// resulting dangling bytes instead of leaving them for
114		// encoding/json to turn into a garbled U+FFFD.
115		body = strings.ToValidUTF8(body[:maxBodyBytes], "")
116	}
117	payload, err := json.Marshal(map[string]any{
118		"aps": map[string]any{
119			"alert":     map[string]string{"title": title, "body": body},
120			"sound":     "default",
121			"thread-id": title,
122		},
123		"path": path,
124	})
125	if err != nil {
126		return resultDead, 0, err
127	}
128	bearer, err := c.tokens.token()
129	if err != nil {
130		return resultRetry, 0, err
131	}
132	url := c.scheme + "://" + c.host + "/3/device/" + token
133	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
134	if err != nil {
135		return resultDead, 0, err
136	}
137	req.Header.Set("authorization", "bearer "+bearer)
138	req.Header.Set("apns-topic", c.topic)
139	req.Header.Set("apns-push-type", "alert")
140	req.Header.Set("apns-priority", "10")
141	req.Header.Set("content-type", "application/json")
142
143	resp, err := c.http.Do(req)
144	if err != nil {
145		return resultRetry, 0, err
146	}
147	defer resp.Body.Close()
148	raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
149
150	var apnsErr struct {
151		Reason string `json:"reason"`
152	}
153	json.Unmarshal(raw, &apnsErr)
154
155	var after time.Duration
156	if v := resp.Header.Get("Retry-After"); v != "" {
157		if n, err := strconv.Atoi(v); err == nil && n > 0 {
158			after = time.Duration(n) * time.Second
159		}
160	}
161
162	switch {
163	case resp.StatusCode == http.StatusOK:
164		return resultSent, 0, nil
165	case resp.StatusCode == http.StatusGone,
166		apnsErr.Reason == "BadDeviceToken",
167		apnsErr.Reason == "Unregistered":
168		// Apple is authoritative about which tokens are live.
169		return resultReap, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
170	case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
171		return resultRetry, after, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
172	default:
173		// Retrying a rejected payload will not fix it.
174		return resultDead, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
175	}
176}