package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "strings" "sync" "time" "lchat/internal/apns" ) // APNs support for the native iOS app ("Eleven"). Mirrors the Web Push path: // the payload carries only the ciphertext blob + thread id — the app's // Notification Service Extension decrypts on-device with keys from the // Keychain, so the E2EE rule holds end to end (Apple relays ciphertext). // // Configured entirely by env; with no key configured the whole transport is a // no-op, so instances without an APNs key behave exactly as before. Two // transports (see newAPNsClient): direct mode holds the .p8 and calls Apple // itself; relay mode holds no key and POSTs to push.elevenmessenger.com, which // signs on its behalf — the p8 then lives only on the relay. // // Direct mode: // LCHAT_APNS_KEY — the .p8 auth key (PEM), or base64 of it // LCHAT_APNS_KEY_ID — the 10-char key id // LCHAT_APNS_TEAM_ID — the Apple team id // LCHAT_APNS_TOPIC — the app's bundle id // // Relay mode (takes precedence when both are set): // LCHAT_PUSH_RELAY_URL — the relay base, e.g. https://push.elevenmessenger.com // LCHAT_PUSH_RELAY_TOKEN — this instance's per-instance bearer token // // Tokens are stored per device-session token (like Web Push subscriptions), so // the same sender/active-user suppression rules apply. A token registered from // a debug build sets sandbox=1 and is delivered via Apple's sandbox gateway. // APNsToken is one registered iOS device, tied to the session token that made it. // Only a hash of that token is persisted (it's a live credential); Token is the // raw value on the way in, TokenHash is what comes back out and is matched on. type APNsToken struct { APNsToken string Token string // raw session token — input to addAPNsToken only, never stored TokenHash string // hashToken(Token); the persisted, matched-on value Sandbox bool } func (db *DB) addAPNsToken(t APNsToken) error { sandbox := 0 if t.Sandbox { sandbox = 1 } // Persist only the token hash; the plaintext `token` column stays empty. _, err := db.sql.Exec( `INSERT INTO apns_tokens(apns_token, token, token_hash, sandbox, created_at) VALUES(?, '', ?, ?, ?) ON CONFLICT(apns_token) DO UPDATE SET token='', token_hash=excluded.token_hash, sandbox=excluded.sandbox`, t.APNsToken, hashToken(t.Token), sandbox, time.Now().Unix(), ) return err } func (db *DB) listAPNsTokens() ([]APNsToken, error) { rows, err := db.sql.Query("SELECT apns_token, COALESCE(token_hash, ''), sandbox FROM apns_tokens") if err != nil { return nil, err } defer rows.Close() out := []APNsToken{} for rows.Next() { var t APNsToken var sandbox int if err := rows.Scan(&t.APNsToken, &t.TokenHash, &sandbox); err != nil { return nil, err } t.Sandbox = sandbox == 1 out = append(out, t) } return out, rows.Err() } func (db *DB) deleteAPNsToken(apnsToken string) error { _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE apns_token = ?", apnsToken) return err } func (db *DB) deleteAPNsTokensForToken(token string) error { _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE token_hash = ?", hashToken(token)) return err } // apnsSender abstracts "deliver this ciphertext blob to this device". Two // implementations, chosen from env by newAPNsClient: directSender signs with a // local .p8 and calls Apple; relaySender holds no key and POSTs to the central // push relay. nil means APNs is disabled (Web Push only), exactly as before. type apnsSender interface { send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error // sendAlert carries a PLAINTEXT banner — only ever the changelog // announcement (announce.go), which is branding-class text, not user // content. Same ErrGone/prune contract as send. sendAlert(deviceToken string, sandbox bool, title, body string) error } // directSender is today's transport: the .p8 lives in this instance's env and // the shared client (internal/apns) signs + calls Apple directly. type directSender struct{ c *apns.Client } func (d directSender) send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error { return d.c.Send(deviceToken, sandbox, threadID, blob, id, at) } func (d directSender) sendAlert(deviceToken string, sandbox bool, title, body string) error { return d.c.SendAlert(deviceToken, sandbox, title, body) } // relaySender forwards to push.elevenmessenger.com. It posts the same // {device_token, sandbox, thread, blob} the relay needs — all opaque, no key — // authenticated with this instance's per-instance bearer token. The relay // normalises Apple's "token gone" to HTTP 410, which we map back to // apns.ErrGone so apnsFanOut's existing prune-on-gone path is untouched. Any // other non-2xx (relay auth/rate-limit/error) is a plain error and NEVER a // prune signal. type relaySender struct { url string // full endpoint, e.g. https://push.elevenmessenger.com/api/push token string http *http.Client } func (r relaySender) send(deviceToken string, sandbox bool, threadID, blob string, id, at int64) error { // id/at ride along for the NSE's local-store ingest (see apns.BuildPayload); // an older relay just ignores the extra fields and the feature stays off. return r.post(map[string]any{ "device_token": deviceToken, "sandbox": sandbox, "thread": threadID, "blob": blob, "id": id, "at": at, }) } func (r relaySender) sendAlert(deviceToken string, sandbox bool, title, body string) error { // An older relay ignores title/body and forwards an empty-blob push — the // NSE can't decrypt it, so the admin sees the generic "New message" // placeholder until the relay is redeployed. Degraded, never broken. return r.post(map[string]any{ "device_token": deviceToken, "sandbox": sandbox, "changelog": true, "title": title, "body": body, }) } func (r relaySender) post(fields map[string]any) error { body, _ := json.Marshal(fields) req, err := http.NewRequest("POST", r.url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("authorization", "Bearer "+r.token) req.Header.Set("content-type", "application/json") resp, err := r.http.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512)) // drain for reuse switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: return nil case resp.StatusCode == http.StatusGone: return apns.ErrGone default: return fmt.Errorf("apns relay: status %d", resp.StatusCode) } } // newAPNsClient picks the APNs transport from env, relay first: // // LCHAT_PUSH_RELAY_URL + LCHAT_PUSH_RELAY_TOKEN → relay mode (no p8 here) // LCHAT_APNS_KEY + _KEY_ID + _TEAM_ID + _TOPIC → direct mode (p8 in this env) // neither → disabled (Web Push only) // // Relay-wins lets an instance migrate in two safe steps: set the relay vars (it // flips to relay while any leftover p8 sits unused), then drop the p8 later. func newAPNsClient() apnsSender { if url := os.Getenv("LCHAT_PUSH_RELAY_URL"); url != "" { if tok := os.Getenv("LCHAT_PUSH_RELAY_TOKEN"); tok != "" { log.Printf("apns: relay mode via %s", url) return relaySender{ url: strings.TrimRight(url, "/") + "/api/push", token: tok, http: &http.Client{Timeout: 20 * time.Second}, } } log.Println("apns: LCHAT_PUSH_RELAY_URL set without LCHAT_PUSH_RELAY_TOKEN — ignoring") } c, err := apns.New( os.Getenv("LCHAT_APNS_KEY"), os.Getenv("LCHAT_APNS_KEY_ID"), os.Getenv("LCHAT_APNS_TEAM_ID"), os.Getenv("LCHAT_APNS_TOPIC"), ) if err != nil { // Incomplete config = APNs off (Web Push only), as before. Only shout // when SOME key material was present but wrong, to aid debugging. if os.Getenv("LCHAT_APNS_KEY") != "" { log.Println("apns:", err, "— APNs disabled") } return nil } log.Printf("apns: configured (team %s, key %s, topic %s)", os.Getenv("LCHAT_APNS_TEAM_ID"), os.Getenv("LCHAT_APNS_KEY_ID"), c.Topic()) return directSender{c: c} } // apnsFanOut applies the selectRecipients rules to APNs tokens (keep the filter // in lockstep with push.go's selectRecipients) and sends concurrently. No-op // when APNs isn't configured. func (s *Server) apnsFanOut(threadID, blob string, id, at int64, exceptToken string, skipActive bool, recipients map[string]bool) int { if s.apns == nil { return 0 } tokens, err := s.db.listAPNsTokens() if err != nil { log.Println("apns: list tokens:", err) return 0 } return s.apnsSendSelected("msg", tokens, exceptToken, skipActive, recipients, func(t APNsToken) error { return s.apns.send(t.APNsToken, t.Sandbox, threadID, blob, id, at) }) } // apnsAnnounce carries the plaintext changelog banner (announce.go) to the // admins' iPhones — the APNs twin of the Web Push side of announceChangelog. // No sender to exclude and no active-skip, for the same reasons as there. The // alert payload has a hard OS cap, so the body is truncated tighter than Web // Push's. func (s *Server) apnsAnnounce(title, body string, recipients map[string]bool) int { if s.apns == nil { return 0 } tokens, err := s.db.listAPNsTokens() if err != nil { log.Println("apns: list tokens:", err) return 0 } if r := []rune(body); len(r) > 800 { body = string(r[:800]) + "…" } return s.apnsSendSelected("changelog", tokens, "", false, recipients, func(t APNsToken) error { return s.apns.sendAlert(t.APNsToken, t.Sandbox, title, body) }) } // apnsSendSelected applies the selectRecipients rules to APNs tokens (keep the // filter in lockstep with push.go's selectRecipients) and sends concurrently, // pruning tokens Apple reports gone. func (s *Server) apnsSendSelected(kind string, tokens []APNsToken, exceptToken string, skipActive bool, recipients map[string]bool, sendOne func(APNsToken) error) int { senderUser := "" exceptHash := "" if exceptToken != "" { exceptHash = hashToken(exceptToken) senderUser, _ = s.db.deviceTokenUser(exceptToken) } sem := make(chan struct{}, maxPushConcurrency) var wg sync.WaitGroup sent := 0 selected := 0 var mu sync.Mutex for _, t := range tokens { if exceptHash != "" && t.TokenHash == exceptHash { continue } uid, known := s.db.deviceTokenUserByHash(t.TokenHash) if senderUser != "" && known && uid == senderUser { continue } if skipActive && s.isUserActive(uid) { continue } if recipients != nil && (!known || !recipients[uid]) { continue } selected++ wg.Add(1) sem <- struct{}{} go func(t APNsToken) { defer wg.Done() defer func() { <-sem }() err := sendOne(t) switch { case err == nil: mu.Lock() sent++ mu.Unlock() case errors.Is(err, apns.ErrGone): _ = s.db.deleteAPNsToken(t.APNsToken) default: log.Println("apns:", err) } }(t) } wg.Wait() // Mirror push.go's summary line — silence here is indistinguishable from // "no registered iPhones", which makes push failures undebuggable. log.Printf("apns[%s]: %d token(s), %d after exclusions, %d sent", kind, len(tokens), selected, sent) return sent }