| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 47 | type 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 | |
| 54 | func (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 | |
| 69 | func (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 | |
| 88 | func (db *DB) deleteAPNsToken(apnsToken string) error { |
| 89 | _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE apns_token = ?", apnsToken) |
| 90 | return err |
| 91 | } |
| 92 | |
| 93 | func (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. |
| 102 | type 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. |
| 112 | type directSender struct{ c *apns.Client } |
| 113 | |
| 114 | func (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 | |
| 118 | func (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. |
| 129 | type relaySender struct { |
| 130 | url string // full endpoint, e.g. https://push.elevenmessenger.com/api/push |
| 131 | token string |
| 132 | http *http.Client |
| 133 | } |
| 134 | |
| 135 | func (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 | |
| 144 | func (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 | |
| 154 | func (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. |
| 186 | func 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. |
| 220 | func (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. |
| 239 | func (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. |
| 259 | func (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 | |