internal/push/apns.go
188 lines · 6119 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 // siteURL is this instance, as the alert reports it. With the
44 // recipient's username it identifies the account a notice belongs
45 // to, which a device signed in to several cannot otherwise tell.
46 siteURL string
47}
48
49func NewClient(cfg config.Push, siteURL string) (*Client, error) {
50 c := &Client{
51 // stdlib negotiates HTTP/2 over ALPN, which is what APNs
52 // requires; no explicit http2 transport is needed.
53 http: &http.Client{Timeout: 30 * time.Second},
54 host: cfg.Host(),
55 scheme: apnsScheme(),
56 topic: cfg.Topic,
57 siteURL: siteURL,
58 }
59 if cfg.KeyFile != "" {
60 key, err := config.LoadAPNSKey(cfg.KeyFile)
61 if err != nil {
62 return nil, err
63 }
64 c.key = key
65 c.tokens = newTokenSource(key, cfg.KeyID, cfg.TeamID)
66 }
67 return c, nil
68}
69
70// apnsScheme is https for the real Apple hosts. GITBAY_APNS_HOST redirects
71// the endpoint for tests (config.Push.Host), and the fake it points at
72// speaks plain HTTP/1.1 rather than negotiating TLS, so the same override
73// has to drop the scheme too, or every request fails with "server gave
74// HTTP response to HTTPS client" instead of reaching the fake at all.
75//
76// The drop only applies to a host on this machine. The provider token is
77// a bearer credential, valid for an hour and good for any device under
78// the topic; putting it on the wire in cleartext to somewhere else is not
79// a thing the test override should be able to arrange. Every fake in the
80// tree is an httptest server, which always binds loopback, so nothing
81// loses anything by the restriction. Either way the decision is logged,
82// so an operator who set the variable learns what it did.
83func apnsScheme() string {
84 h := os.Getenv("GITBAY_APNS_HOST")
85 if h == "" {
86 return "https"
87 }
88 if !loopbackHost(h) {
89 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)
90 return "https"
91 }
92 slog.Warn("push: GITBAY_APNS_HOST is set, sending to it over plain HTTP instead of APNs", "host", h)
93 return "http"
94}
95
96// loopbackHost reports whether a host:port names this machine. The port
97// is optional: config.Push.Host returns a bare hostname for the real
98// endpoints, and the override may or may not carry one.
99func loopbackHost(hostport string) bool {
100 host := hostport
101 if h, _, err := net.SplitHostPort(hostport); err == nil {
102 host = h
103 }
104 host = strings.Trim(host, "[]")
105 if host == "localhost" {
106 return true
107 }
108 ip := net.ParseIP(host)
109 return ip != nil && ip.IsLoopback()
110}
111
112// Send delivers one alert. The returned duration is the server's
113// Retry-After when it gave one, zero otherwise.
114func (c *Client) Send(ctx context.Context, token, user string, badge int, title, body, path string) (result, time.Duration, error) {
115 if len(body) > maxBodyBytes {
116 // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji,
117 // accents, non-Latin usernames). ToValidUTF8 drops the
118 // resulting dangling bytes instead of leaving them for
119 // encoding/json to turn into a garbled U+FFFD.
120 body = strings.ToValidUTF8(body[:maxBodyBytes], "")
121 }
122 payload, err := json.Marshal(map[string]any{
123 "aps": map[string]any{
124 "alert": map[string]string{"title": title, "body": body},
125 "sound": "default",
126 "badge": badge,
127 "thread-id": title,
128 },
129 "path": path,
130 // Which account this is for. A device token is one install, and
131 // an install registers against every account signed in on it, so
132 // path alone is ambiguous — two instances can hold the same
133 // owner/name. Together these are the account's identity.
134 "instance": c.siteURL,
135 "user": user,
136 })
137 if err != nil {
138 return resultDead, 0, err
139 }
140 bearer, err := c.tokens.token()
141 if err != nil {
142 return resultRetry, 0, err
143 }
144 url := c.scheme + "://" + c.host + "/3/device/" + token
145 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
146 if err != nil {
147 return resultDead, 0, err
148 }
149 req.Header.Set("authorization", "bearer "+bearer)
150 req.Header.Set("apns-topic", c.topic)
151 req.Header.Set("apns-push-type", "alert")
152 req.Header.Set("apns-priority", "10")
153 req.Header.Set("content-type", "application/json")
154
155 resp, err := c.http.Do(req)
156 if err != nil {
157 return resultRetry, 0, err
158 }
159 defer resp.Body.Close()
160 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
161
162 var apnsErr struct {
163 Reason string `json:"reason"`
164 }
165 json.Unmarshal(raw, &apnsErr)
166
167 var after time.Duration
168 if v := resp.Header.Get("Retry-After"); v != "" {
169 if n, err := strconv.Atoi(v); err == nil && n > 0 {
170 after = time.Duration(n) * time.Second
171 }
172 }
173
174 switch {
175 case resp.StatusCode == http.StatusOK:
176 return resultSent, 0, nil
177 case resp.StatusCode == http.StatusGone,
178 apnsErr.Reason == "BadDeviceToken",
179 apnsErr.Reason == "Unregistered":
180 // Apple is authoritative about which tokens are live.
181 return resultReap, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
182 case resp.StatusCode == http.StatusTooManyRequests, resp.StatusCode >= 500:
183 return resultRetry, after, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
184 default:
185 // Retrying a rejected payload will not fix it.
186 return resultDead, 0, fmt.Errorf("apns %d %s", resp.StatusCode, apnsErr.Reason)
187 }
188}