Files
Commilitia-Drop/internal/apns/sender.go
T
admin a2cad11224 后台/会话三诉求:子会话(一台设备一会话)+ 离线消息队列 + 被登出推送 + 后台唤醒层
- Req 1 子会话(iOS 多进程在用户视角=一台设备一会话,内部多条独立刷新逻辑会话):brokerclient 加 MintParams.Sub + SessionInfo.Sub + RevokeDeviceSessions(user,meta) 级联;device-session sub!="" 挂在主 device_id 之下、走 tier=clipboard 且不建第二个 device 行;handleSessionsList 按 meta 归并、隐藏 sub!=""(但保留“仅控件会话存活”的设备,不简单丢弃);revokeDevice 改走 meta 级联(移除设备连带吊控件子会话)。iOS provisionWidgetSessionIfNeeded 改用主 device_id + sub="widget"(弃用独立 dev_widget),控件令牌仍隔离持有、独立刷新——不碰引擎主会话,#7 隔离不变。蓝本 auth/docs/子会话方案.md(cdrop 提案 + Broker 评审接受 + cdrop 确认 6 点:用 sub、R1 cdrop 归并、scope 复用 tier=clipboard、级联 DELETE …?meta=)。
- Req 2a 离线消息队列:新增 pending_messages 表(0001_init + sqlc 查询);message.go 收件设备离线即入队(无论是否配推送都入队,恒 202),修“离线消息只随推送横幅一闪、不入收件列表”;GET /api/messages/pending 取即删(DELETE..RETURNING,单次投递);web hub.ts onOpen 每次 SSE 连接 / 重连即拉取补收、逐条 addMessage(全端受益,iOS 经桥推原生 + 累积未读);复用 login-request reaper 清 TTL。
- Req 3 被登出推送:push 加 KindSessionRevoked + 本地化文案,apns/web 双通道;revokeDevice 加 notifyRevoked 参(跨端 revoke=true、自登出=false),跨端移除时推送告知被踢设备;iOS AppDelegate 收 session:revoked 即清 Keychain 主会话 + 控件会话、发 .cdropSessionRevoked,前台经 AppRoot 即时回登录页、关闭态下次启动即登出。
- #6 后台唤醒层:apns apsEnvelope 加 content-available:1(服务端有消息时既弹可点横幅又短暂后台唤醒);iOS DeviceItem 加 Codable、presence 快照持久化(冷启即时显示设备列表,缓解“长时间重连”观感,SSE 一连即整组替换自校正);控件会话回前台补铸(scenePhase active)+ content-available 唤醒时刷新隔离的控件会话(剪贴板保活,绝不碰引擎主会话以免 #7 回归)。
- 测试:qr_test mock broker 加 sub 幂等键 + 级联吊销端点;bootstrap_test 期望表集加 pending_messages。
2026-06-27 17:42:53 +08:00

301 lines
9.5 KiB
Go

// Package apns delivers Apple Push Notification service (APNs) notifications to
// iOS devices whose SSE connection is closed. It mirrors the shape of the push
// (Web Push) package: an inert Sender when unconfigured, per-subscription
// localization via the shared push.Localize machinery, and automatic pruning of
// dead device tokens on HTTP 410 (Unregistered) responses.
//
// APNs uses provider-token authentication: a cached ES256 JWT signed with the
// team's .p8 key, refreshed every ~50 minutes (Apple requires 20-60 minutes).
// HTTP/2 is negotiated automatically by Go's net/http Transport over TLS.
package apns
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/push"
)
// apnsTimeout bounds a single Notify call (including all subscriptions for one
// device), so a hung APNs endpoint cannot leak goroutines.
const apnsTimeout = 15 * time.Second
// jwtRefreshAge is how long we cache a provider JWT before minting a fresh one.
// Apple requires a new token between 20 and 60 minutes; 50 minutes gives a safe margin.
const jwtRefreshAge = 50 * time.Minute
// prodBase and sandboxBase are the APNs HTTP/2 endpoints.
const (
prodBase = "https://api.push.apple.com"
sandboxBase = "https://api.sandbox.push.apple.com"
)
// apsPayload is the JSON body for an APNs alert notification.
type apsPayload struct {
APS apsEnvelope `json:"aps"`
Type string `json:"type"`
URL string `json:"url"`
}
type apsEnvelope struct {
Alert apsAlert `json:"alert"`
Sound string `json:"sound"`
// ContentAvailable=1 gives the app a brief background wake (in addition to the
// visible banner) so it can warm up — refresh the isolated widget/clipboard
// session, re-establish state — without the user tapping. iOS rate-limits these,
// and they ride the existing offline alert (push-type stays "alert"), so a server
// message is the only trigger. The app never holds a persistent connection.
ContentAvailable int `json:"content-available,omitempty"`
}
type apsAlert struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Sender delivers APNs notifications and prunes dead device tokens. A Sender
// built without all required config (key/keyID/teamID/topic) is inert: Enabled
// reports false and Notify is a no-op. Safe for concurrent use.
type Sender struct {
queries *db.Queries
key *ecdsa.PrivateKey
keyID string
teamID string
topic string
base string // APNs base URL; settable for tests
client *http.Client
mu sync.Mutex
cachedJWT string
jwtMintedAt time.Time
}
// NewSender returns a configured APNs Sender. keyPEM is the .p8 PKCS8 EC private
// key Apple issues. env is "prod" (default) or "sandbox". When any of keyPEM,
// keyID, teamID, or topic is empty/nil the returned Sender is inert (Enabled
// returns false, Notify is a no-op) so callers can invoke it unconditionally.
// A PEM parse failure is logged and also yields an inert Sender rather than a
// hard error, so startup can proceed with APNs disabled.
func NewSender(queries *db.Queries, keyPEM []byte, keyID, teamID, topic, env string) *Sender {
if len(keyPEM) == 0 || keyID == "" || teamID == "" || topic == "" {
return &Sender{}
}
block, _ := pem.Decode(keyPEM)
if block == nil {
slog.Error("apns: cannot decode PEM block from .p8 key; APNs disabled")
return &Sender{}
}
raw, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
slog.Error("apns: cannot parse PKCS8 private key; APNs disabled", "err", err)
return &Sender{}
}
ecKey, ok := raw.(*ecdsa.PrivateKey)
if !ok {
slog.Error("apns: .p8 key is not an ECDSA private key; APNs disabled")
return &Sender{}
}
base := prodBase
switch strings.ToLower(strings.TrimSpace(env)) {
case "sandbox":
base = sandboxBase
case "", "prod", "production":
base = prodBase
default:
// Unknown env is an operator typo; default to prod but log loudly so the
// sandbox/prod mismatch footgun (dev tokens → prod endpoint → BadDeviceToken)
// surfaces instead of silently selecting the wrong endpoint.
slog.Warn("apns: unknown CDROP_APNS_ENV, defaulting to production", "env", env)
}
return &Sender{
queries: queries,
key: ecKey,
keyID: keyID,
teamID: teamID,
topic: topic,
base: base,
client: &http.Client{Timeout: apnsTimeout},
}
}
// Enabled reports whether APNs is configured.
func (s *Sender) Enabled() bool { return s != nil && s.key != nil }
// Notify delivers n to every APNs subscription of (userID, deviceName).
// Best-effort and safe to call in a goroutine: failures are logged, a token the
// APNs service marks Unregistered (410) is pruned, and a no-op results when
// disabled or the device has no APNs subscriptions.
func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n push.Notification) {
if !s.Enabled() {
return
}
ctx, cancel := context.WithTimeout(ctx, apnsTimeout)
defer cancel()
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(ctx, db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: userID,
DeviceName: deviceName,
})
if err != nil {
slog.Error("apns: list subscriptions failed",
"user", userID, "device", deviceName, "err", err)
return
}
if len(subs) == 0 {
return
}
jwtStr, err := s.providerJWT()
if err != nil {
slog.Error("apns: jwt signing failed", "err", err)
return
}
for _, sub := range subs {
title, body := push.Localize(n.Type, n.Params, sub.Locale)
url := n.URL
if url == "" {
url = "/"
}
payload := apsPayload{
APS: apsEnvelope{
Alert: apsAlert{Title: title, Body: body},
Sound: "default",
ContentAvailable: 1,
},
Type: n.Type,
URL: url,
}
s.send(ctx, sub.ID, sub.Endpoint, jwtStr, payload)
}
}
func (s *Sender) send(
ctx context.Context,
subID, deviceToken, jwtStr string,
payload apsPayload,
) {
body, err := json.Marshal(payload)
if err != nil {
slog.Error("apns: marshal payload failed", "err", err)
return
}
reqURL := fmt.Sprintf("%s/3/device/%s", s.base, deviceToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
if err != nil {
slog.Error("apns: build request failed", "err", err)
return
}
req.Header.Set("Authorization", "bearer "+jwtStr)
req.Header.Set("apns-topic", s.topic)
req.Header.Set("apns-push-type", "alert")
req.Header.Set("apns-priority", "10")
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
slog.Warn("apns: send failed", "token_prefix", tokenPrefix(deviceToken), "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
LastUsedAt: time.Now().Unix(),
ID: subID,
})
return
}
// Non-2xx: APNs returns a small JSON body whose `reason` names the exact failure
// (BadDeviceToken, InvalidProviderToken, BadTopic, ExpiredProviderToken, …). It is
// the single most useful diagnostic, so always surface it rather than logging a
// bare numeric status.
reason := apnsReason(resp.Body)
switch {
case resp.StatusCode == http.StatusGone, reason == "BadDeviceToken", reason == "DeviceTokenNotForTopic":
// 410 Unregistered, or a permanently-invalid token (the app was uninstalled,
// the token rotated, or it's malformed / for the wrong topic). Prune the row
// so we stop re-firing a guaranteed-fail request on every future Notify.
if err := s.queries.DeletePushSubscription(ctx, subID); err != nil {
slog.Error("apns: prune dead token failed", "id", subID, "reason", reason, "err", err)
}
case reason == "ExpiredProviderToken":
// Clock skew pushed the cached provider JWT past Apple's 60-min limit before
// our 50-min refresh. Bust the cache so the next call re-mints immediately;
// otherwise every push fails for the rest of the refresh window.
s.mu.Lock()
s.cachedJWT = ""
s.mu.Unlock()
slog.Warn("apns: provider token expired, busting cache", "token_prefix", tokenPrefix(deviceToken))
default:
slog.Warn("apns: rejected",
"status", resp.StatusCode, "reason", reason, "token_prefix", tokenPrefix(deviceToken))
}
}
// apnsReason extracts the `reason` field from an APNs error response body (small
// JSON like {"reason":"BadDeviceToken"}). Returns "" if absent or unparseable.
func apnsReason(body io.Reader) string {
var e struct {
Reason string `json:"reason"`
}
_ = json.NewDecoder(io.LimitReader(body, 1024)).Decode(&e)
return e.Reason
}
// providerJWT returns a cached ES256 provider JWT, re-minting when the cached
// token is older than jwtRefreshAge. Safe for concurrent callers (mutex-protected).
func (s *Sender) providerJWT() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cachedJWT != "" && time.Since(s.jwtMintedAt) < jwtRefreshAge {
return s.cachedJWT, nil
}
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": s.teamID,
"iat": now.Unix(),
})
tok.Header["kid"] = s.keyID
signed, err := tok.SignedString(s.key)
if err != nil {
return "", fmt.Errorf("sign apns jwt: %w", err)
}
s.cachedJWT = signed
s.jwtMintedAt = now
return signed, nil
}
// tokenPrefix returns the first 8 hex chars of a device token for log
// messages — enough to correlate without exposing the full opaque token.
func tokenPrefix(token string) string {
if len(token) <= 8 {
return token
}
return token[:8] + "..."
}