44096069a7
- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource - 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI - APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式 - Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎) - 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换) - 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册 - I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键 - ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
294 lines
9.0 KiB
Go
294 lines
9.0 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"`
|
|
}
|
|
|
|
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",
|
|
},
|
|
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] + "..."
|
|
}
|