eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "os"
12 "strings"
13 "sync"
14 "time"
15
16 "lchat/internal/apns"
17)
18
19// APNs support for the native iOS app ("Eleven"). Mirrors the Web Push path:
20// the payload carries only the ciphertext blob + thread id — the app's
21// Notification Service Extension decrypts on-device with keys from the
22// Keychain, so the E2EE rule holds end to end (Apple relays ciphertext).
23//
24// Configured entirely by env; with no key configured the whole transport is a
25// no-op, so instances without an APNs key behave exactly as before. Two
26// transports (see newAPNsClient): direct mode holds the .p8 and calls Apple
27// itself; relay mode holds no key and POSTs to push.elevenmessenger.com, which
28// signs on its behalf — the p8 then lives only on the relay.
29//
30// Direct mode:
31// LCHAT_APNS_KEY — the .p8 auth key (PEM), or base64 of it
32// LCHAT_APNS_KEY_ID — the 10-char key id
33// LCHAT_APNS_TEAM_ID — the Apple team id
34// LCHAT_APNS_TOPIC — the app's bundle id
35//
36// Relay mode (takes precedence when both are set):
37// LCHAT_PUSH_RELAY_URL — the relay base, e.g. https://push.elevenmessenger.com
38// LCHAT_PUSH_RELAY_TOKEN — this instance's per-instance bearer token
39//
40// Tokens are stored per device-session token (like Web Push subscriptions), so
41// the same sender/active-user suppression rules apply. A token registered from
42// a debug build sets sandbox=1 and is delivered via Apple's sandbox gateway.
43
44// APNsToken is one registered iOS device, tied to the session token that made it.
45// Only a hash of that token is persisted (it's a live credential); Token is the
46// raw value on the way in, TokenHash is what comes back out and is matched on.
47type APNsToken struct {
48 APNsToken string
49 Token string // raw session token — input to addAPNsToken only, never stored
50 TokenHash string // hashToken(Token); the persisted, matched-on value
51 Sandbox bool
52}
53
54func (db *DB) addAPNsToken(t APNsToken) error {
55 sandbox := 0
56 if t.Sandbox {
57 sandbox = 1
58 }
59 // Persist only the token hash; the plaintext `token` column stays empty.
60 _, err := db.sql.Exec(
61 `INSERT INTO apns_tokens(apns_token, token, token_hash, sandbox, created_at)
62 VALUES(?, '', ?, ?, ?)
63 ON CONFLICT(apns_token) DO UPDATE SET token='', token_hash=excluded.token_hash, sandbox=excluded.sandbox`,
64 t.APNsToken, hashToken(t.Token), sandbox, time.Now().Unix(),
65 )
66 return err
67}
68
69func (db *DB) listAPNsTokens() ([]APNsToken, error) {
70 rows, err := db.sql.Query("SELECT apns_token, COALESCE(token_hash, ''), sandbox FROM apns_tokens")
71 if err != nil {
72 return nil, err
73 }
74 defer rows.Close()
75 out := []APNsToken{}
76 for rows.Next() {
77 var t APNsToken
78 var sandbox int
79 if err := rows.Scan(&t.APNsToken, &t.TokenHash, &sandbox); err != nil {
80 return nil, err
81 }
82 t.Sandbox = sandbox == 1
83 out = append(out, t)
84 }
85 return out, rows.Err()
86}
87
88func (db *DB) deleteAPNsToken(apnsToken string) error {
89 _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE apns_token = ?", apnsToken)
90 return err
91}
92
93func (db *DB) deleteAPNsTokensForToken(token string) error {
94 _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE token_hash = ?", hashToken(token))
95 return err
96}
97
98// apnsSender abstracts "deliver this ciphertext blob to this device". Two
99// implementations, chosen from env by newAPNsClient: directSender signs with a
100// local .p8 and calls Apple; relaySender holds no key and POSTs to the central
101// push relay. nil means APNs is disabled (Web Push only), exactly as before.
102type apnsSender interface {
103 send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error
104 // sendAlert carries a PLAINTEXT banner — only ever the changelog
105 // announcement (announce.go), which is branding-class text, not user
106 // content. Same ErrGone/prune contract as send.
107 sendAlert(deviceToken string, sandbox bool, title, body string) error
108}
109
110// directSender is today's transport: the .p8 lives in this instance's env and
111// the shared client (internal/apns) signs + calls Apple directly.
112type directSender struct{ c *apns.Client }
113
114func (d directSender) send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error {
115 return d.c.Send(deviceToken, sandbox, threadID, blob, id, at)
116}
117
118func (d directSender) sendAlert(deviceToken string, sandbox bool, title, body string) error {
119 return d.c.SendAlert(deviceToken, sandbox, title, body)
120}
121
122// relaySender forwards to push.elevenmessenger.com. It posts the same
123// {device_token, sandbox, thread, blob} the relay needs — all opaque, no key —
124// authenticated with this instance's per-instance bearer token. The relay
125// normalises Apple's "token gone" to HTTP 410, which we map back to
126// apns.ErrGone so apnsFanOut's existing prune-on-gone path is untouched. Any
127// other non-2xx (relay auth/rate-limit/error) is a plain error and NEVER a
128// prune signal.
129type relaySender struct {
130 url string // full endpoint, e.g. https://push.elevenmessenger.com/api/push
131 token string
132 http *http.Client
133}
134
135func (r relaySender) send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error {
136 // id/at ride along for the NSE's local-store ingest (see apns.BuildPayload);
137 // an older relay just ignores the extra fields and the feature stays off.
138 return r.post(map[string]any{
139 "device_token": deviceToken, "sandbox": sandbox, "thread": threadID, "blob": blob,
140 "id": id, "at": at,
141 })
142}
143
144func (r relaySender) sendAlert(deviceToken string, sandbox bool, title, body string) error {
145 // An older relay ignores title/body and forwards an empty-blob push — the
146 // NSE can't decrypt it, so the admin sees the generic "New message"
147 // placeholder until the relay is redeployed. Degraded, never broken.
148 return r.post(map[string]any{
149 "device_token": deviceToken, "sandbox": sandbox,
150 "changelog": true, "title": title, "body": body,
151 })
152}
153
154func (r relaySender) post(fields map[string]any) error {
155 body, _ := json.Marshal(fields)
156 req, err := http.NewRequest("POST", r.url, bytes.NewReader(body))
157 if err != nil {
158 return err
159 }
160 req.Header.Set("authorization", "Bearer "+r.token)
161 req.Header.Set("content-type", "application/json")
162 resp, err := r.http.Do(req)
163 if err != nil {
164 return err
165 }
166 defer resp.Body.Close()
167 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512)) // drain for reuse
168 switch {
169 case resp.StatusCode >= 200 && resp.StatusCode < 300:
170 return nil
171 case resp.StatusCode == http.StatusGone:
172 return apns.ErrGone
173 default:
174 return fmt.Errorf("apns relay: status %d", resp.StatusCode)
175 }
176}
177
178// newAPNsClient picks the APNs transport from env, relay first:
179//
180// LCHAT_PUSH_RELAY_URL + LCHAT_PUSH_RELAY_TOKEN → relay mode (no p8 here)
181// LCHAT_APNS_KEY + _KEY_ID + _TEAM_ID + _TOPIC → direct mode (p8 in this env)
182// neither → disabled (Web Push only)
183//
184// Relay-wins lets an instance migrate in two safe steps: set the relay vars (it
185// flips to relay while any leftover p8 sits unused), then drop the p8 later.
186func newAPNsClient() apnsSender {
187 if url := os.Getenv("LCHAT_PUSH_RELAY_URL"); url != "" {
188 if tok := os.Getenv("LCHAT_PUSH_RELAY_TOKEN"); tok != "" {
189 log.Printf("apns: relay mode via %s", url)
190 return relaySender{
191 url: strings.TrimRight(url, "/") + "/api/push",
192 token: tok,
193 http: &http.Client{Timeout: 20 * time.Second},
194 }
195 }
196 log.Println("apns: LCHAT_PUSH_RELAY_URL set without LCHAT_PUSH_RELAY_TOKEN — ignoring")
197 }
198 c, err := apns.New(
199 os.Getenv("LCHAT_APNS_KEY"),
200 os.Getenv("LCHAT_APNS_KEY_ID"),
201 os.Getenv("LCHAT_APNS_TEAM_ID"),
202 os.Getenv("LCHAT_APNS_TOPIC"),
203 )
204 if err != nil {
205 // Incomplete config = APNs off (Web Push only), as before. Only shout
206 // when SOME key material was present but wrong, to aid debugging.
207 if os.Getenv("LCHAT_APNS_KEY") != "" {
208 log.Println("apns:", err, "— APNs disabled")
209 }
210 return nil
211 }
212 log.Printf("apns: configured (team %s, key %s, topic %s)",
213 os.Getenv("LCHAT_APNS_TEAM_ID"), os.Getenv("LCHAT_APNS_KEY_ID"), c.Topic())
214 return directSender{c: c}
215}
216
217// apnsFanOut applies the selectRecipients rules to APNs tokens (keep the filter
218// in lockstep with push.go's selectRecipients) and sends concurrently. No-op
219// when APNs isn't configured.
220func (s *Server) apnsFanOut(threadID, blob string, id, at int64, exceptToken string, skipActive bool, recipients map[string]bool) int {
221 if s.apns == nil {
222 return 0
223 }
224 tokens, err := s.db.listAPNsTokens()
225 if err != nil {
226 log.Println("apns: list tokens:", err)
227 return 0
228 }
229 return s.apnsSendSelected("msg", tokens, exceptToken, skipActive, recipients, func(t APNsToken) error {
230 return s.apns.send(t.APNsToken, t.Sandbox, threadID, blob, id, at)
231 })
232}
233
234// apnsAnnounce carries the plaintext changelog banner (announce.go) to the
235// admins' iPhones — the APNs twin of the Web Push side of announceChangelog.
236// No sender to exclude and no active-skip, for the same reasons as there. The
237// alert payload has a hard OS cap, so the body is truncated tighter than Web
238// Push's.
239func (s *Server) apnsAnnounce(title, body string, recipients map[string]bool) int {
240 if s.apns == nil {
241 return 0
242 }
243 tokens, err := s.db.listAPNsTokens()
244 if err != nil {
245 log.Println("apns: list tokens:", err)
246 return 0
247 }
248 if r := []rune(body); len(r) > 800 {
249 body = string(r[:800]) + "…"
250 }
251 return s.apnsSendSelected("changelog", tokens, "", false, recipients, func(t APNsToken) error {
252 return s.apns.sendAlert(t.APNsToken, t.Sandbox, title, body)
253 })
254}
255
256// apnsSendSelected applies the selectRecipients rules to APNs tokens (keep the
257// filter in lockstep with push.go's selectRecipients) and sends concurrently,
258// pruning tokens Apple reports gone.
259func (s *Server) apnsSendSelected(kind string, tokens []APNsToken, exceptToken string, skipActive bool, recipients map[string]bool, sendOne func(APNsToken) error) int {
260 senderUser := ""
261 exceptHash := ""
262 if exceptToken != "" {
263 exceptHash = hashToken(exceptToken)
264 senderUser, _ = s.db.deviceTokenUser(exceptToken)
265 }
266 sem := make(chan struct{}, maxPushConcurrency)
267 var wg sync.WaitGroup
268 sent := 0
269 selected := 0
270 var mu sync.Mutex
271 for _, t := range tokens {
272 if exceptHash != "" && t.TokenHash == exceptHash {
273 continue
274 }
275 uid, known := s.db.deviceTokenUserByHash(t.TokenHash)
276 if senderUser != "" && known && uid == senderUser {
277 continue
278 }
279 if skipActive && s.isUserActive(uid) {
280 continue
281 }
282 if recipients != nil && (!known || !recipients[uid]) {
283 continue
284 }
285 selected++
286 wg.Add(1)
287 sem <- struct{}{}
288 go func(t APNsToken) {
289 defer wg.Done()
290 defer func() { <-sem }()
291 err := sendOne(t)
292 switch {
293 case err == nil:
294 mu.Lock()
295 sent++
296 mu.Unlock()
297 case errors.Is(err, apns.ErrGone):
298 _ = s.db.deleteAPNsToken(t.APNsToken)
299 default:
300 log.Println("apns:", err)
301 }
302 }(t)
303 }
304 wg.Wait()
305 // Mirror push.go's summary line — silence here is indistinguishable from
306 // "no registered iPhones", which makes push failures undebuggable.
307 log.Printf("apns[%s]: %d token(s), %d after exclusions, %d sent", kind, len(tokens), selected, sent)
308 return sent
309}
310