internal/push/apns.go

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

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}