iOS 计划完成:流式发送 / 后台续传 / APNs / Share Extension / 控制中心控件 + 真机分发预备

- 发送端流式(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 回调
This commit is contained in:
2026-06-27 00:27:55 +08:00
parent 10cf36ecee
commit 44096069a7
59 changed files with 2585 additions and 144 deletions
+293
View File
@@ -0,0 +1,293 @@
// 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] + "..."
}
+396
View File
@@ -0,0 +1,396 @@
package apns
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/push"
)
// generateTestKey creates an ephemeral P-256 key and returns it as PEM-encoded
// PKCS8 bytes alongside the typed private key (for verification).
func generateTestKey(t *testing.T) (pemBytes []byte, priv *ecdsa.PrivateKey) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
pemBytes = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
return pemBytes, priv
}
// openTestDB creates a temporary SQLite database and runs Bootstrap. Returns a
// *db.Queries backed by that DB.
func openTestDB(t *testing.T) *db.Queries {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "apns_test.db"))
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
return db.New(conn)
}
// TestNewSenderInertWhenUnconfigured verifies that a Sender with any missing
// config field is inert: Enabled reports false and Notify is a safe no-op.
func TestNewSenderInertWhenUnconfigured(t *testing.T) {
keyPEM, _ := generateTestKey(t)
cases := []struct {
name string
keyPEM []byte
keyID, teamID, topic string
}{
{"no key", nil, "KID", "TID", "topic"},
{"no keyID", keyPEM, "", "TID", "topic"},
{"no teamID", keyPEM, "KID", "", "topic"},
{"no topic", keyPEM, "KID", "TID", ""},
{"all empty", nil, "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := NewSender(nil, tc.keyPEM, tc.keyID, tc.teamID, tc.topic, "sandbox")
if s.Enabled() {
t.Fatal("inert Sender should report Enabled=false")
}
// Notify must be a safe no-op with nil queries.
s.Notify(context.Background(), "u", "d", push.Notification{Type: push.KindMessage})
})
}
}
// TestNilSenderIsSafe verifies that a nil *Sender is safe to call.
func TestNilSenderIsSafe(t *testing.T) {
var s *Sender
if s.Enabled() {
t.Fatal("nil sender should report disabled")
}
s.Notify(context.Background(), "u", "d", push.Notification{}) // must not panic
}
// TestProviderJWT verifies the JWT structure and ES256 signature.
func TestProviderJWT(t *testing.T) {
const keyID = "TESTKEYID1"
const teamID = "TEAMID1234"
keyPEM, priv := generateTestKey(t)
s := NewSender(nil, keyPEM, keyID, teamID, "com.example.app", "sandbox")
if !s.Enabled() {
t.Fatal("sender should be enabled with a valid key")
}
jwtStr, err := s.providerJWT()
if err != nil {
t.Fatalf("providerJWT: %v", err)
}
if jwtStr == "" {
t.Fatal("providerJWT returned empty string")
}
// Parse without verification first to inspect headers.
tok, err := jwt.NewParser().ParseWithClaims(jwtStr, jwt.MapClaims{}, func(t *jwt.Token) (any, error) {
return &priv.PublicKey, nil
})
if err != nil {
t.Fatalf("parse jwt: %v", err)
}
if !tok.Valid {
t.Fatal("jwt should be valid")
}
// Header checks.
alg, _ := tok.Header["alg"].(string)
if alg != "ES256" {
t.Errorf("header alg = %q, want ES256", alg)
}
kid, _ := tok.Header["kid"].(string)
if kid != keyID {
t.Errorf("header kid = %q, want %q", kid, keyID)
}
// Claims checks.
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
t.Fatal("claims not MapClaims")
}
iss, _ := claims["iss"].(string)
if iss != teamID {
t.Errorf("claim iss = %q, want %q", iss, teamID)
}
iat, ok := claims["iat"]
if !ok {
t.Error("claim iat missing")
}
// iat should be a recent unix timestamp (within the last minute).
iatF, _ := iat.(float64)
iatTime := time.Unix(int64(iatF), 0)
if time.Since(iatTime) > time.Minute {
t.Errorf("claim iat too old: %v", iatTime)
}
}
// TestJWTIsCached verifies that repeated calls within jwtRefreshAge return the
// same token string, and that forcing the cache stale mints a new one.
func TestJWTIsCached(t *testing.T) {
keyPEM, _ := generateTestKey(t)
s := NewSender(nil, keyPEM, "KID", "TID", "topic", "sandbox")
first, err := s.providerJWT()
if err != nil {
t.Fatalf("first jwt: %v", err)
}
second, err := s.providerJWT()
if err != nil {
t.Fatalf("second jwt: %v", err)
}
if first != second {
t.Error("providerJWT should return the cached token within refresh window")
}
// Expire the cache.
s.mu.Lock()
s.jwtMintedAt = time.Now().Add(-(jwtRefreshAge + time.Second))
s.mu.Unlock()
third, err := s.providerJWT()
if err != nil {
t.Fatalf("third jwt: %v", err)
}
if third == first {
t.Error("providerJWT should mint a new token after refresh window expires")
}
}
// apnsServerRecorder is a minimal httptest server that records the last request
// and responds with a configurable status.
type apnsServerRecorder struct {
srv *httptest.Server
lastPath string
lastAuth string
lastTopic string
lastPushType string
lastBody []byte
respondWith int
}
func newAPNsTestServer(t *testing.T, respondWith int) *apnsServerRecorder {
t.Helper()
rec := &apnsServerRecorder{respondWith: respondWith}
rec.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.lastPath = r.URL.Path
rec.lastAuth = r.Header.Get("Authorization")
rec.lastTopic = r.Header.Get("apns-topic")
rec.lastPushType = r.Header.Get("apns-push-type")
rec.lastBody, _ = io.ReadAll(r.Body)
w.WriteHeader(rec.respondWith)
}))
t.Cleanup(rec.srv.Close)
return rec
}
// TestSendRequest asserts that Notify fires a POST with the correct path and
// headers against the APNs base URL, and that the JSON body has the expected shape.
func TestSendRequest(t *testing.T) {
const deviceToken = "abc123deadbeef"
const topic = "com.example.cdrop"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusOK)
s := NewSender(q, keyPEM, "KID", "TID", topic, "sandbox")
if !s.Enabled() {
t.Fatal("sender must be enabled")
}
// Point the sender at the test server instead of the real APNs endpoint.
s.base = rec.srv.URL
// Insert an APNs subscription row for user "u", device "d".
now := time.Now().Unix()
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(deviceToken),
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindTransferIncoming,
Params: map[string]string{"sender": "Mac", "filename": "a.pdf"},
Tag: "transfer:1",
URL: "/transfers",
})
// Path must be /3/device/{token}.
wantPath := "/3/device/" + deviceToken
if rec.lastPath != wantPath {
t.Errorf("path = %q, want %q", rec.lastPath, wantPath)
}
// Authorization header must start with "bearer ".
if !strings.HasPrefix(rec.lastAuth, "bearer ") {
t.Errorf("Authorization = %q, want bearer ...", rec.lastAuth)
}
// APNs headers.
if rec.lastTopic != topic {
t.Errorf("apns-topic = %q, want %q", rec.lastTopic, topic)
}
if rec.lastPushType != "alert" {
t.Errorf("apns-push-type = %q, want alert", rec.lastPushType)
}
// JSON body shape.
var body apsPayload
if err := json.Unmarshal(rec.lastBody, &body); err != nil {
t.Fatalf("parse body: %v (raw: %s)", err, rec.lastBody)
}
if body.Type != push.KindTransferIncoming {
t.Errorf("body.type = %q, want %q", body.Type, push.KindTransferIncoming)
}
if body.APS.Alert.Title != "Incoming file" {
t.Errorf("body.aps.alert.title = %q, want Incoming file", body.APS.Alert.Title)
}
if !strings.Contains(body.APS.Alert.Body, "a.pdf") {
t.Errorf("body.aps.alert.body = %q, want mention of a.pdf", body.APS.Alert.Body)
}
if body.URL != "/transfers" {
t.Errorf("body.url = %q, want /transfers", body.URL)
}
if body.APS.Sound != "default" {
t.Errorf("body.aps.sound = %q, want default", body.APS.Sound)
}
}
// TestSend410PrunesRow verifies that an HTTP 410 response from the APNs
// endpoint causes the subscription row to be deleted from the database.
func TestSend410PrunesRow(t *testing.T) {
const deviceToken = "deadbeef410token"
q := openTestDB(t)
keyPEM, _ := generateTestKey(t)
rec := newAPNsTestServer(t, http.StatusGone) // 410 = Unregistered
s := NewSender(q, keyPEM, "KID", "TID", "com.example.app", "sandbox")
s.base = rec.srv.URL
now := time.Now().Unix()
subID := push.SubscriptionID(deviceToken)
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: subID,
UserID: "u",
DeviceName: "d",
Endpoint: deviceToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("seed subscription: %v", err)
}
s.Notify(context.Background(), "u", "d", push.Notification{
Type: push.KindMessage,
Params: map[string]string{
"sender": "Phone",
"text": "hello",
},
})
// The row must be gone.
subs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "d",
})
if err != nil {
t.Fatalf("list subs: %v", err)
}
if len(subs) != 0 {
t.Errorf("subscription row should be pruned on 410, got %d rows", len(subs))
}
}
// TestWebPushRowsNotReturnedByAPNsQuery asserts that WebPush-platform rows are
// invisible to ListAPNsSubscriptionsByUserDevice and vice versa.
func TestWebPushRowsNotReturnedByAPNsQuery(t *testing.T) {
q := openTestDB(t)
now := time.Now().Unix()
// Insert a webpush row.
const webEndpoint = "https://fcm.googleapis.com/fake"
if err := q.UpsertPushSubscription(context.Background(), db.UpsertPushSubscriptionParams{
ID: push.SubscriptionID(webEndpoint),
UserID: "u",
DeviceName: "d",
Endpoint: webEndpoint,
P256dh: "p",
Auth: "a",
UserAgent: "",
Locale: "en-US",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert webpush: %v", err)
}
// APNs query must not see the webpush row.
apnsSubs, err := q.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list apns: %v", err)
}
if len(apnsSubs) != 0 {
t.Errorf("APNs query returned %d row(s), want 0 (webpush rows must be filtered)", len(apnsSubs))
}
// WebPush query must not see apns rows.
const apnsToken = "aabbccdd1234"
if err := q.UpsertAPNsSubscription(context.Background(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(apnsToken),
UserID: "u",
DeviceName: "d",
Endpoint: apnsToken,
Locale: "zh-CN",
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
t.Fatalf("upsert apns: %v", err)
}
webSubs, err := q.ListPushSubscriptionsByUserDevice(context.Background(), db.ListPushSubscriptionsByUserDeviceParams{
UserID: "u", DeviceName: "d",
})
if err != nil {
t.Fatalf("list webpush: %v", err)
}
if len(webSubs) != 1 {
t.Errorf("webpush query returned %d row(s), want 1 (apns rows must be filtered)", len(webSubs))
}
}
+57
View File
@@ -13,6 +13,11 @@ import (
"github.com/knadh/koanf/v2"
)
// errAPNsHalfConfig is returned when some but not all APNs fields are set.
var errAPNsHalfConfig = errors.New(
"CDROP_APNS_KEY_PATH, CDROP_APNS_KEY_ID, CDROP_APNS_TEAM_ID, and CDROP_APNS_TOPIC " +
"must all be set together, or none at all (partial config is always an operator error)")
const envPrefix = "CDROP_"
type Config struct {
@@ -86,6 +91,24 @@ type Config struct {
VAPIDPrivateKey string `koanf:"vapid_private_key"`
VAPIDSubject string `koanf:"vapid_subject"`
// APNs (Apple Push Notification service): iOS device push channel.
// All four fields must be set together or none (partial config is an error).
// APNSKeyPath — filesystem path to the .p8 (PEM PKCS8 ES256) key Apple issues.
// APNSKeyID — the 10-character Key ID from the Apple Developer portal.
// APNSTeamID — the 10-character Team ID from the Apple Developer portal.
// APNSTopic — the app bundle ID (e.g. "net.commilitia.cdrop").
// APNSEnv — "prod" (default) or "sandbox" (dev/TestFlight builds).
// When unset, APNs is disabled and the register endpoint returns 503.
APNSKeyPath string `koanf:"apns_key_path"`
APNSKeyID string `koanf:"apns_key_id"`
APNSTeamID string `koanf:"apns_team_id"`
APNSTopic string `koanf:"apns_topic"`
APNSEnv string `koanf:"apns_env"`
// apnsKeyPEM is the loaded .p8 file contents, populated by Load() when
// APNSKeyPath is set. Callers read it via APNSKeyPEM().
apnsKeyPEM []byte
// 扫码登录(QR):新设备出码、已登录设备扫码批准,经 Auth Broker 委托签发会话。
// QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503。QRRequestTTLSeconds 是一条扫码
// 请求的存活秒数(默认 120)。批准后铸的会话寿命取上面的按档 TTL(full / guest)。
@@ -151,6 +174,17 @@ func Load() (*Config, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
// Load the APNs .p8 key file after validation (validate() already confirmed
// that if APNSKeyPath is set, all other APNs fields are also set).
if cfg.APNSKeyPath != "" {
pem, err := os.ReadFile(cfg.APNSKeyPath)
if err != nil {
return nil, fmt.Errorf("read CDROP_APNS_KEY_PATH %q: %w", cfg.APNSKeyPath, err)
}
cfg.apnsKeyPEM = pem
}
return cfg, nil
}
@@ -195,9 +229,32 @@ func (c *Config) validate() error {
"CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " +
"(run `just vapid-keygen`); set neither to disable Web Push")
}
// APNs is optional, but partial config is always an operator error: all four
// fields must be present for APNs to function. Count how many are set; any
// non-zero count that is not four is a mistake.
apnsSet := 0
for _, v := range []string{c.APNSKeyPath, c.APNSKeyID, c.APNSTeamID, c.APNSTopic} {
if v != "" {
apnsSet++
}
}
if apnsSet > 0 && apnsSet < 4 {
return errAPNsHalfConfig
}
return nil
}
// APNSKeyPEM returns the loaded .p8 key contents. Empty when APNs is
// unconfigured (APNSKeyPath was not set).
func (c *Config) APNSKeyPEM() []byte { return c.apnsKeyPEM }
// APNSEnabled reports whether all APNs fields are configured.
func (c *Config) APNSEnabled() bool {
return c.APNSKeyPath != "" && c.APNSKeyID != "" && c.APNSTeamID != "" && c.APNSTopic != ""
}
// PushEnabled reports whether Web Push is configured (both VAPID keys present).
func (c *Config) PushEnabled() bool {
return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != ""
+6
View File
@@ -59,6 +59,12 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err)
}
// push_subscriptions gained a platform column to distinguish Web Push (VAPID)
// from APNs (iOS device token) rows. Existing rows default to 'webpush'.
if err := ensureColumn(ctx, tx, "push_subscriptions", "platform",
"ALTER TABLE push_subscriptions ADD COLUMN platform TEXT NOT NULL DEFAULT 'webpush'"); err != nil {
return fmt.Errorf("migrate push_subscriptions.platform: %w", err)
}
// devices moved to a stable opaque device_id primary key (Auth Broker path A);
// the old table was keyed (user_id, name). Device rows are ephemeral registrations
// re-created on the next request / scan, so a legacy table is dropped and recreated
@@ -0,0 +1,7 @@
-- Additive migration: add platform column to push_subscriptions to distinguish
-- Web Push (VAPID, browser/PWA) from APNs (iOS device token) rows.
-- This file is the sqlc-facing schema declaration; it is NOT embedded or run
-- directly. At runtime the column is added by ensureColumn(...,"platform",...) in
-- bootstrap.go (idempotent ALTER). Note: 0001_init.sql's CREATE TABLE does NOT
-- include this column — bootstrap's ensureColumn is the sole source on fresh DBs.
ALTER TABLE push_subscriptions ADD COLUMN platform TEXT NOT NULL DEFAULT 'webpush';
+1
View File
@@ -53,6 +53,7 @@ type PushSubscription struct {
Locale string `json:"locale"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
Platform string `json:"platform"`
}
type TransferSession struct {
+125 -15
View File
@@ -34,26 +34,40 @@ func (q *Queries) DeletePushSubscriptionsByUserDevice(ctx context.Context, arg D
return err
}
const listPushSubscriptionsByUserDevice = `-- name: ListPushSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at
const listAPNsSubscriptionsByUserDevice = `-- name: ListAPNsSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions
WHERE user_id = ? AND device_name = ?
WHERE user_id = ? AND device_name = ? AND platform = 'apns'
`
type ListPushSubscriptionsByUserDeviceParams struct {
type ListAPNsSubscriptionsByUserDeviceParams struct {
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
}
func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg ListPushSubscriptionsByUserDeviceParams) ([]PushSubscription, error) {
rows, err := q.db.QueryContext(ctx, listPushSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
type ListAPNsSubscriptionsByUserDeviceRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
Platform string `json:"platform"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
}
func (q *Queries) ListAPNsSubscriptionsByUserDevice(ctx context.Context, arg ListAPNsSubscriptionsByUserDeviceParams) ([]ListAPNsSubscriptionsByUserDeviceRow, error) {
rows, err := q.db.QueryContext(ctx, listAPNsSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PushSubscription
var items []ListAPNsSubscriptionsByUserDeviceRow
for rows.Next() {
var i PushSubscription
var i ListAPNsSubscriptionsByUserDeviceRow
if err := rows.Scan(
&i.ID,
&i.UserID,
@@ -63,6 +77,67 @@ func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg Lis
&i.Auth,
&i.UserAgent,
&i.Locale,
&i.Platform,
&i.CreatedAt,
&i.LastUsedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPushSubscriptionsByUserDevice = `-- name: ListPushSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions
WHERE user_id = ? AND device_name = ? AND platform = 'webpush'
`
type ListPushSubscriptionsByUserDeviceParams struct {
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
}
type ListPushSubscriptionsByUserDeviceRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
Platform string `json:"platform"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
}
func (q *Queries) ListPushSubscriptionsByUserDevice(ctx context.Context, arg ListPushSubscriptionsByUserDeviceParams) ([]ListPushSubscriptionsByUserDeviceRow, error) {
rows, err := q.db.QueryContext(ctx, listPushSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPushSubscriptionsByUserDeviceRow
for rows.Next() {
var i ListPushSubscriptionsByUserDeviceRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.DeviceName,
&i.Endpoint,
&i.P256dh,
&i.Auth,
&i.UserAgent,
&i.Locale,
&i.Platform,
&i.CreatedAt,
&i.LastUsedAt,
); err != nil {
@@ -95,10 +170,44 @@ func (q *Queries) TouchPushSubscription(ctx context.Context, arg TouchPushSubscr
return err
}
const upsertAPNsSubscription = `-- name: UpsertAPNsSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, '', '', '', ?, 'apns', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at
`
type UpsertAPNsSubscriptionParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
Endpoint string `json:"endpoint"`
Locale string `json:"locale"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
}
func (q *Queries) UpsertAPNsSubscription(ctx context.Context, arg UpsertAPNsSubscriptionParams) error {
_, err := q.db.ExecContext(ctx, upsertAPNsSubscription,
arg.ID,
arg.UserID,
arg.DeviceName,
arg.Endpoint,
arg.Locale,
arg.CreatedAt,
arg.LastUsedAt,
)
return err
}
const upsertPushSubscription = `-- name: UpsertPushSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'webpush', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
@@ -106,6 +215,7 @@ ON CONFLICT(id) DO UPDATE SET
auth = excluded.auth,
user_agent = excluded.user_agent,
locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at
`
@@ -122,11 +232,11 @@ type UpsertPushSubscriptionParams struct {
LastUsedAt int64 `json:"last_used_at"`
}
// push_subscriptions: Web Push endpoints for browsers / PWAs. id is the hex
// SHA-256 of the endpoint, so a re-subscribe from the same browser upserts in
// place rather than piling up duplicate rows. Lookups are by (user_id,
// device_name): the receiving device of a notify-worthy event. Dead endpoints
// (push service returns 404/410) are pruned by DeletePushSubscription.
// push_subscriptions: Web Push and APNs subscription records.
// id is hex SHA-256(endpoint/device-token), making upsert idempotent.
// platform distinguishes 'webpush' (browser VAPID) from 'apns' (iOS device token).
// Lookups are by (user_id, device_name, platform) so each channel only sees its own rows.
// Dead endpoints (push service returns 404/410, APNs returns 410) are pruned by DeletePushSubscription.
// NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on
// multibyte runes in query files, corrupting the generated SQL.
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) error {
+25 -9
View File
@@ -1,14 +1,14 @@
-- push_subscriptions: Web Push endpoints for browsers / PWAs. id is the hex
-- SHA-256 of the endpoint, so a re-subscribe from the same browser upserts in
-- place rather than piling up duplicate rows. Lookups are by (user_id,
-- device_name): the receiving device of a notify-worthy event. Dead endpoints
-- (push service returns 404/410) are pruned by DeletePushSubscription.
-- push_subscriptions: Web Push and APNs subscription records.
-- id is hex SHA-256(endpoint/device-token), making upsert idempotent.
-- platform distinguishes 'webpush' (browser VAPID) from 'apns' (iOS device token).
-- Lookups are by (user_id, device_name, platform) so each channel only sees its own rows.
-- Dead endpoints (push service returns 404/410, APNs returns 410) are pruned by DeletePushSubscription.
-- NOTE: keep this file pure ASCII; sqlc v1.31.1 drifts byte offsets on
-- multibyte runes in query files, corrupting the generated SQL.
-- name: UpsertPushSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'webpush', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
@@ -16,12 +16,28 @@ ON CONFLICT(id) DO UPDATE SET
auth = excluded.auth,
user_agent = excluded.user_agent,
locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at;
-- name: ListPushSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions
WHERE user_id = ? AND device_name = ?;
WHERE user_id = ? AND device_name = ? AND platform = 'webpush';
-- name: ListAPNsSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at
FROM push_subscriptions
WHERE user_id = ? AND device_name = ? AND platform = 'apns';
-- name: UpsertAPNsSubscription :exec
INSERT INTO push_subscriptions (id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, platform, created_at, last_used_at)
VALUES (?, ?, ?, ?, '', '', '', ?, 'apns', ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
locale = excluded.locale,
platform = excluded.platform,
last_used_at = excluded.last_used_at;
-- name: TouchPushSubscription :exec
UPDATE push_subscriptions
+14 -7
View File
@@ -80,13 +80,20 @@ func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) {
})
if !delivered {
// Peer's page is closed (no live SSE). The message has no DB row, so the
// only delivery left is a Web Push carrying the text itself. If the peer
// has a subscription, send it and report 202; otherwise it's truly gone.
if s.push.Enabled() {
go s.push.Notify(context.Background(), claims.UserID, req.To, push.Notification{
Type: push.KindMessage,
Params: map[string]string{"sender": from, "text": req.Text},
})
// only delivery left is a push notification (Web Push or APNs) carrying
// the text itself. If any push channel is enabled and may have a
// subscription, send and report 202; otherwise the message is truly gone.
n := push.Notification{
Type: push.KindMessage,
Params: map[string]string{"sender": from, "text": req.Text},
}
if s.push.Enabled() || s.apns.Enabled() {
if s.push.Enabled() {
go s.push.Notify(context.Background(), claims.UserID, req.To, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), claims.UserID, req.To, n)
}
w.WriteHeader(http.StatusAccepted)
return
}
+60
View File
@@ -10,6 +10,10 @@ import (
"commilitia.net/cdrop/internal/push"
)
// maxAPNsRegisterBytes caps the register body. A device token is 64 hex chars
// plus the locale field; 1 KB is generous headroom.
const maxAPNsRegisterBytes = 1024
// maxPushSubscribeBytes caps the subscribe/unsubscribe body. A PushSubscription
// JSON is well under 1 KB (endpoint URL + two short keys); 8 KB is generous
// headroom while still bounding per-request memory.
@@ -96,6 +100,62 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// apnsRegisterReq is the body for POST /api/push/apns/register.
// DeviceToken is the hex-encoded APNs device token the iOS app received from
// the system. Locale is optional; unknown or empty falls back to the server default.
type apnsRegisterReq struct {
DeviceToken string `json:"device_token"`
Locale string `json:"locale"`
}
// handleAPNsRegister stores (or refreshes) an iOS device's APNs token for the
// calling session's device. Idempotent: re-registering the same token upserts
// in place (SHA-256 of the token is the primary key). Returns 204 on success or
// 503 when APNs is not configured.
func (s *Server) handleAPNsRegister(w http.ResponseWriter, r *http.Request) {
if !s.apns.Enabled() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "apns disabled"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
if device == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"})
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAPNsRegisterBytes)
var req apnsRegisterReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.DeviceToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device_token"})
return
}
locale := req.Locale
if !knownLocales[locale] {
locale = "zh-CN"
}
now := time.Now().Unix()
if err := s.queries.UpsertAPNsSubscription(r.Context(), db.UpsertAPNsSubscriptionParams{
ID: push.SubscriptionID(req.DeviceToken),
UserID: claims.UserID,
DeviceName: device,
Endpoint: req.DeviceToken,
Locale: locale,
CreatedAt: now,
LastUsedAt: now,
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store failed"})
return
}
w.WriteHeader(http.StatusNoContent)
}
type pushUnsubscribeReq struct {
Endpoint string `json:"endpoint"`
}
+212
View File
@@ -0,0 +1,212 @@
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push"
)
// apnsTestKeyPEM generates an ephemeral P-256 key and returns its PEM bytes so
// tests can build an enabled apns.Sender without real Apple credentials.
func apnsTestKeyPEM(t *testing.T) []byte {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
}
// newAPNsTestServer builds a minimal Server with a real in-memory SQLite DB
// and an enabled apns.Sender (using an ephemeral test key). The apns.Sender
// base is left at the default; the endpoint test only exercises DB writes, so
// the Sender never fires a real APNs request.
func newAPNsTestServer(t *testing.T) *Server {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "push_test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
q := db.New(conn)
apnsSender := apns.NewSender(q, apnsTestKeyPEM(t), "TESTKID", "TESTTEAM", "com.example.cdrop", "sandbox")
if !apnsSender.Enabled() {
t.Fatal("apns sender must be enabled for this test")
}
return &Server{
queries: q,
hub: hub.New(q),
apns: apnsSender,
}
}
// TestHandleAPNsRegister_DisabledReturns503 verifies that the register endpoint
// returns 503 when APNs is not configured (inert Sender).
func TestHandleAPNsRegister_DisabledReturns503(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "push_test2.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
q := db.New(conn)
s := &Server{
queries: q,
hub: hub.New(q),
apns: apns.NewSender(nil, nil, "", "", "", ""), // inert
}
body := `{"device_token":"aabbccdd1234"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("disabled: got %d, want 503", w.Code)
}
}
// TestHandleAPNsRegister_StoresRowIdempotently verifies that POST /api/push/apns/register
// stores the subscription row and that a second call with the same token upserts
// in place (idempotent: still exactly one row).
func TestHandleAPNsRegister_StoresRowIdempotently(t *testing.T) {
s := newAPNsTestServer(t)
const deviceToken = "aabbccdd1234deadbeef"
const locale = "en-US"
callRegister := func() int {
body := `{"device_token":"` + deviceToken + `","locale":"` + locale + `"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
return w.Code
}
// First registration.
if code := callRegister(); code != http.StatusNoContent {
t.Fatalf("first register: got %d, want 204", code)
}
// Verify the row exists.
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list subs: %v", err)
}
if len(subs) != 1 {
t.Fatalf("after first register: got %d rows, want 1", len(subs))
}
sub := subs[0]
if sub.Endpoint != deviceToken {
t.Errorf("endpoint = %q, want %q", sub.Endpoint, deviceToken)
}
if sub.Locale != locale {
t.Errorf("locale = %q, want %q", sub.Locale, locale)
}
if sub.Platform != "apns" {
t.Errorf("platform = %q, want apns", sub.Platform)
}
if sub.UserID != "u" {
t.Errorf("user_id = %q, want u", sub.UserID)
}
expectedID := push.SubscriptionID(deviceToken)
if sub.ID != expectedID {
t.Errorf("id = %q, want %q (SHA-256 of token)", sub.ID, expectedID)
}
// Second registration with same token: must upsert in place.
if code := callRegister(); code != http.StatusNoContent {
t.Fatalf("second register: got %d, want 204", code)
}
subs2, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list subs after second register: %v", err)
}
if len(subs2) != 1 {
t.Errorf("after second register: got %d rows, want 1 (should upsert not duplicate)", len(subs2))
}
}
// TestHandleAPNsRegister_MissingToken verifies that a missing device_token
// returns 400.
func TestHandleAPNsRegister_MissingToken(t *testing.T) {
s := newAPNsTestServer(t)
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(`{}`))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("missing token: got %d, want 400", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err == nil {
if resp["error"] != "missing device_token" {
t.Errorf("error msg = %q", resp["error"])
}
}
}
// TestHandleAPNsRegister_UnknownLocaleFallsBack verifies that an unrecognized
// locale falls back to the server default (zh-CN).
func TestHandleAPNsRegister_UnknownLocaleFallsBack(t *testing.T) {
s := newAPNsTestServer(t)
body := `{"device_token":"tok123456","locale":"fr-FR"}`
r := httptest.NewRequest(http.MethodPost, "/api/push/apns/register", strings.NewReader(body))
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", Scope: "full"}))
r = r.WithContext(jwtauth.ContextWithDeviceName(r.Context(), "iPhone"))
w := httptest.NewRecorder()
s.handleAPNsRegister(w, r)
if w.Code != http.StatusNoContent {
t.Fatalf("register: got %d, want 204", w.Code)
}
subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(context.Background(), db.ListAPNsSubscriptionsByUserDeviceParams{
UserID: "u",
DeviceName: "iPhone",
})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(subs) != 1 {
t.Fatalf("want 1 row, got %d", len(subs))
}
if subs[0].Locale != "zh-CN" {
t.Errorf("locale = %q, want zh-CN (fallback)", subs[0].Locale)
}
}
+5
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/brokerclient"
"commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard"
@@ -36,6 +37,7 @@ type Server struct {
calls *calls.Provider // optional; nil → STUN-only fallback
clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard
push *push.Sender // inert when VAPID keys unset → push endpoints 503
apns *apns.Sender // inert when APNs keys unset → register endpoint 503
mux *chi.Mux
// broker delegates session lifecycle to the Auth Broker (mint / revoke / refresh).
@@ -56,6 +58,7 @@ func New(
cp *calls.Provider,
clip *clipboard.Service,
pushSender *push.Sender,
apnsSender *apns.Sender,
) *Server {
s := &Server{
cfg: cfg,
@@ -68,6 +71,7 @@ func New(
calls: cp,
clipboard: clip,
push: pushSender,
apns: apnsSender,
mux: chi.NewRouter(),
broker: brokerclient.New(cfg.BrokerBaseURL, cfg.BrokerInternalKey, cfg.BrokerAppOrDefault()),
@@ -147,6 +151,7 @@ func (s *Server) routes() {
r.Get("/push/vapid-key", s.handlePushVAPIDKey)
r.Post("/push/subscribe", s.handlePushSubscribe)
r.Delete("/push/subscribe", s.handlePushUnsubscribe)
r.Post("/push/apns/register", s.handleAPNsRegister)
r.Get("/calls/credentials", s.handleCallsCredentials)
// Full sessions only — restricted guests (scan-login borrows) are
+7
View File
@@ -68,6 +68,13 @@ func DeviceNameFromContext(ctx context.Context) (string, bool) {
return n, ok
}
// ContextWithDeviceName attaches a device name to the context — the inverse of
// DeviceNameFromContext. Used by tests that exercise handlers directly without
// running the full auth middleware.
func ContextWithDeviceName(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, deviceCtxKey, name)
}
// DeviceTypeFromContext returns the client-declared device type set by the auth
// middleware (browser / macos / windows / linux / ios), defaulting to "browser".
func DeviceTypeFromContext(ctx context.Context) string {
+13 -6
View File
@@ -137,7 +137,7 @@ func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n Notifi
}
}
func (s *Sender) send(ctx context.Context, sub db.PushSubscription, payload []byte) {
func (s *Sender) send(ctx context.Context, sub db.ListPushSubscriptionsByUserDeviceRow, payload []byte) {
resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
@@ -192,7 +192,10 @@ func endpointHost(endpoint string) string {
return u.Host
}
const defaultLocale = "zh-CN"
// DefaultLocale is the fallback when a subscription carries an unrecognized or
// empty locale. Exported so the apns package (and future channels) can use the
// same constant without re-declaring it.
const DefaultLocale = "zh-CN"
// notifyStrings holds the few server-rendered notification templates, keyed by
// locale then string id. The set mirrors the frontend i18n only for these push
@@ -219,9 +222,9 @@ var notifyStrings = map[string]map[string]string{
}
// render turns an intent into the wire payload, localized to the subscription's
// stored locale. Unknown locales fall back to defaultLocale.
// stored locale. Unknown locales fall back to DefaultLocale.
func render(n Notification, locale string) wirePayload {
title, body := localize(n.Type, n.Params, locale)
title, body := Localize(n.Type, n.Params, locale)
out := wirePayload{Type: n.Type, Title: title, Body: body, Tag: n.Tag, URL: n.URL}
if out.URL == "" {
out.URL = "/"
@@ -229,7 +232,11 @@ func render(n Notification, locale string) wirePayload {
return out
}
func localize(typ string, params map[string]string, locale string) (title, body string) {
// Localize resolves the notification title and body for the given type, params,
// and locale. Unknown locales fall back to DefaultLocale. Exported so other
// delivery channels (e.g. APNs) can share the string tables without duplicating
// them.
func Localize(typ string, params map[string]string, locale string) (title, body string) {
// A message's title is the sender's device name and its body is the user's
// own text — neither is a template, so locale is irrelevant.
if typ == KindMessage {
@@ -238,7 +245,7 @@ func localize(typ string, params map[string]string, locale string) (title, body
t, ok := notifyStrings[locale]
if !ok {
t = notifyStrings[defaultLocale]
t = notifyStrings[DefaultLocale]
}
switch typ {
case KindTransferIncoming:
+1 -1
View File
@@ -107,7 +107,7 @@ func TestRenderUnknownLocaleFallsBackToDefault(t *testing.T) {
Params: map[string]string{"filename": "a.pdf"},
}
got := render(n, "fr-FR")
want := render(n, defaultLocale)
want := render(n, DefaultLocale)
if got.Title != want.Title || got.Body != want.Body {
t.Fatalf("unknown locale did not fall back: got %+v want %+v", got, want)
}
+30 -7
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"time"
"commilitia.net/cdrop/internal/apns"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/push"
@@ -49,12 +50,13 @@ type Service struct {
hub *hub.Hub
relay RelayController
push *push.Sender // inert when Web Push is unconfigured; nil-safe
apns *apns.Sender // inert when APNs is unconfigured; nil-safe
now func() time.Time
newID func() string
}
func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *push.Sender) *Service {
func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *push.Sender, apnsSender *apns.Sender) *Service {
if r == nil {
r = nopRelay{}
}
@@ -63,6 +65,7 @@ func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *
hub: h,
relay: r,
push: pushSender,
apns: apnsSender,
now: time.Now,
newID: defaultNewID,
}
@@ -142,13 +145,21 @@ func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
},
})
// Receiver's page is closed → no SSE → raise a system notification instead.
if !delivered && s.push.Enabled() {
go s.push.Notify(context.Background(), p.UserID, p.ReceiverName, push.Notification{
// Both Web Push (browser/PWA) and APNs (iOS) are attempted; each sender
// only fires for its own platform rows, so firing both is always correct.
if !delivered {
n := push.Notification{
Type: push.KindTransferIncoming,
Params: map[string]string{"sender": p.SenderName, "filename": p.FileName},
Tag: "transfer:" + id,
URL: "/",
})
}
if s.push.Enabled() {
go s.push.Notify(context.Background(), p.UserID, p.ReceiverName, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), p.UserID, p.ReceiverName, n)
}
}
return id, nil
@@ -219,7 +230,9 @@ func (s *Service) Transition(
deliveredReceiver := s.hub.SendTo(userID, sess.ReceiverName, stateEv)
// Terminal completion/failure → notify any party whose page has closed.
if s.push.Enabled() && (target == StateDone || target == StateFailed) {
// Both Web Push and APNs are attempted; disjoint platform rows mean
// firing both is always correct — each sender no-ops for the other's rows.
if (target == StateDone || target == StateFailed) && (s.push.Enabled() || s.apns.Enabled()) {
kind := push.KindTransferDone
if target == StateFailed {
kind = push.KindTransferFailed
@@ -230,10 +243,20 @@ func (s *Service) Transition(
Tag: "transfer:" + sessionID,
}
if !deliveredSender {
go s.push.Notify(context.Background(), userID, sess.SenderName, n)
if s.push.Enabled() {
go s.push.Notify(context.Background(), userID, sess.SenderName, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), userID, sess.SenderName, n)
}
}
if !deliveredReceiver {
go s.push.Notify(context.Background(), userID, sess.ReceiverName, n)
if s.push.Enabled() {
go s.push.Notify(context.Background(), userID, sess.ReceiverName, n)
}
if s.apns.Enabled() {
go s.apns.Notify(context.Background(), userID, sess.ReceiverName, n)
}
}
}
+2 -2
View File
@@ -51,7 +51,7 @@ func newTestService(t *testing.T) *Service {
q := openTestDB(t)
h := newTestHub()
t.Cleanup(h.Close)
return NewService(q, h, nil, nil)
return NewService(q, h, nil, nil, nil)
}
// fakeRelay records Register/Release calls and can be primed to fail Register,
@@ -78,7 +78,7 @@ func newTestServiceWithRelay(t *testing.T, r RelayController) *Service {
q := openTestDB(t)
h := newTestHub()
t.Cleanup(h.Close)
return NewService(q, h, r, nil)
return NewService(q, h, r, nil, nil)
}
func TestInit_AllowsZeroByteFile(t *testing.T) {