internal/push/apns.go
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}