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。
273 lines
9.5 KiB
Go
273 lines
9.5 KiB
Go
// Package push delivers Web Push (RFC 8291/8292) notifications to a user's
|
|
// browser / PWA devices whose page is closed — i.e. has no live SSE connection.
|
|
// The "page open → in-app toast, page closed → system notification" rule lives
|
|
// at the call sites: each notify-worthy event is first handed to hub.SendTo; the
|
|
// push fallback fires only when SendTo reports the device offline.
|
|
//
|
|
// Notification text is rendered here, per subscription, in the device's stored
|
|
// locale (the Service Worker only displays the title/body it receives). Callers
|
|
// describe intent (Type + Params), not pre-rendered strings.
|
|
//
|
|
// Desktop clients never appear here — their Go process is resident and decides
|
|
// locally (window visibility) whether to raise a native OS notification.
|
|
package push
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
webpush "github.com/SherClockHolmes/webpush-go"
|
|
|
|
"commilitia.net/cdrop/internal/db"
|
|
)
|
|
|
|
// pushTimeout bounds a single Notify call (all of one device's subscriptions),
|
|
// so a hung push service can't leak goroutines when callers fire-and-forget.
|
|
const pushTimeout = 15 * time.Second
|
|
|
|
// notificationTTL is how long the push service should retain an undelivered
|
|
// message before dropping it. A day is plenty for "you have an incoming file":
|
|
// past that the transfer has long since expired anyway.
|
|
const notificationTTL = 24 * 60 * 60
|
|
|
|
// Notification kinds. These also travel to the SW as the wire `type` so the
|
|
// client can route a notificationclick (e.g. deep-link to a transfer).
|
|
const (
|
|
KindTransferIncoming = "transfer:incoming"
|
|
KindTransferDone = "transfer:done"
|
|
KindTransferFailed = "transfer:failed"
|
|
KindMessage = "message"
|
|
// KindSessionRevoked tells a device it was logged out by another device (cross-device
|
|
// revoke). The client clears its local session on receipt — immediate even when its page /
|
|
// app is closed, instead of waiting for the next request to 401.
|
|
KindSessionRevoked = "session:revoked"
|
|
)
|
|
|
|
// Notification is an intent to notify; the sender localizes it per subscription.
|
|
// Params carries the substitution values a kind needs:
|
|
// - transfer:incoming → {sender, filename}
|
|
// - transfer:done / transfer:failed → {filename}
|
|
// - message → {sender, text}
|
|
type Notification struct {
|
|
Type string
|
|
Params map[string]string
|
|
Tag string // client-side collapse key; same tag replaces in place
|
|
URL string // where notificationclick navigates; defaults to "/"
|
|
}
|
|
|
|
// wirePayload is the JSON the Service Worker receives and shows verbatim.
|
|
type wirePayload struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Tag string `json:"tag,omitempty"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// Sender delivers notifications and prunes dead subscriptions. A Sender built
|
|
// without VAPID keys is inert: Enabled reports false and Notify is a no-op, so
|
|
// callers can invoke it unconditionally. Safe for concurrent use.
|
|
type Sender struct {
|
|
queries *db.Queries
|
|
pubKey string
|
|
privKey string
|
|
subject string
|
|
client *http.Client
|
|
}
|
|
|
|
// NewSender returns a Sender. With either VAPID key empty it is inert (Web Push
|
|
// disabled). subject is the VAPID contact identity (a mailto: or site URL).
|
|
func NewSender(queries *db.Queries, pubKey, privKey, subject string) *Sender {
|
|
if pubKey == "" || privKey == "" {
|
|
return &Sender{}
|
|
}
|
|
return &Sender{
|
|
queries: queries,
|
|
pubKey: pubKey,
|
|
privKey: privKey,
|
|
subject: subject,
|
|
client: &http.Client{Timeout: pushTimeout},
|
|
}
|
|
}
|
|
|
|
// Enabled reports whether Web Push is configured.
|
|
func (s *Sender) Enabled() bool { return s != nil && s.privKey != "" }
|
|
|
|
// PublicKey returns the VAPID public key the browser needs as
|
|
// applicationServerKey when subscribing. Empty when disabled.
|
|
func (s *Sender) PublicKey() string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return s.pubKey
|
|
}
|
|
|
|
// Notify delivers n to every push subscription of (userID, deviceName).
|
|
// Best-effort and safe to call in a goroutine: failures are logged, a
|
|
// subscription the push service reports gone (404/410) is pruned, and a no-op
|
|
// results when disabled or the device has no subscriptions.
|
|
func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n Notification) {
|
|
if !s.Enabled() {
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, pushTimeout)
|
|
defer cancel()
|
|
|
|
subs, err := s.queries.ListPushSubscriptionsByUserDevice(ctx, db.ListPushSubscriptionsByUserDeviceParams{
|
|
UserID: userID,
|
|
DeviceName: deviceName,
|
|
})
|
|
if err != nil {
|
|
slog.Error("push: list subscriptions failed",
|
|
"user", userID, "device", deviceName, "err", err)
|
|
return
|
|
}
|
|
|
|
for _, sub := range subs {
|
|
payload, err := json.Marshal(render(n, sub.Locale))
|
|
if err != nil {
|
|
slog.Error("push: marshal payload failed", "err", err)
|
|
continue
|
|
}
|
|
s.send(ctx, sub, payload)
|
|
}
|
|
}
|
|
|
|
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},
|
|
}, &webpush.Options{
|
|
HTTPClient: s.client,
|
|
Subscriber: s.subject,
|
|
VAPIDPublicKey: s.pubKey,
|
|
VAPIDPrivateKey: s.privKey,
|
|
TTL: notificationTTL,
|
|
Urgency: webpush.UrgencyHigh,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("push: send failed", "endpoint", endpointHost(sub.Endpoint), "err", err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch {
|
|
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
|
|
// 404/410 means the subscription is permanently gone (the user cleared
|
|
// site data, the browser rotated the endpoint, the PWA was deleted).
|
|
// Pruning keeps the table from accreting dead rows we'd retry forever.
|
|
if err := s.queries.DeletePushSubscription(ctx, sub.ID); err != nil {
|
|
slog.Error("push: prune dead subscription failed", "id", sub.ID, "err", err)
|
|
}
|
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
|
_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
|
|
LastUsedAt: time.Now().Unix(),
|
|
ID: sub.ID,
|
|
})
|
|
default:
|
|
slog.Warn("push: unexpected status",
|
|
"status", resp.StatusCode, "endpoint", endpointHost(sub.Endpoint))
|
|
}
|
|
}
|
|
|
|
// SubscriptionID is the primary key for a subscription: the hex SHA-256 of its
|
|
// endpoint. Hashing makes re-subscribe idempotent and keeps the (long, opaque)
|
|
// endpoint out of the PK while staying deterministic for upsert/delete.
|
|
func SubscriptionID(endpoint string) string {
|
|
sum := sha256.Sum256([]byte(endpoint))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// endpointHost reduces an endpoint URL to its host for logging — the full path
|
|
// is an unguessable capability we should not write to logs.
|
|
func endpointHost(endpoint string) string {
|
|
u, err := url.Parse(endpoint)
|
|
if err != nil || u.Host == "" {
|
|
return "?"
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
// 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
|
|
// strings; message bodies are user text and are never templated here.
|
|
var notifyStrings = map[string]map[string]string{
|
|
"zh-CN": {
|
|
"incoming.title": "收到文件",
|
|
"incoming.body": "%s 发来 %s",
|
|
"done.title": "传输完成",
|
|
"failed.title": "传输失败",
|
|
"revoked.title": "已退出登录",
|
|
"revoked.body": "此设备已被其他设备移除,需重新登录",
|
|
},
|
|
"zh-TW": {
|
|
"incoming.title": "收到檔案",
|
|
"incoming.body": "%s 傳來 %s",
|
|
"done.title": "傳輸完成",
|
|
"failed.title": "傳輸失敗",
|
|
"revoked.title": "已登出",
|
|
"revoked.body": "此裝置已被其他裝置移除,需重新登入",
|
|
},
|
|
"en-US": {
|
|
"incoming.title": "Incoming file",
|
|
"incoming.body": "%s is sending %s",
|
|
"done.title": "Transfer complete",
|
|
"failed.title": "Transfer failed",
|
|
"revoked.title": "Signed out",
|
|
"revoked.body": "This device was removed by another device; sign in again",
|
|
},
|
|
}
|
|
|
|
// render turns an intent into the wire payload, localized to the subscription's
|
|
// stored locale. Unknown locales fall back to DefaultLocale.
|
|
func render(n Notification, locale string) wirePayload {
|
|
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 = "/"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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 {
|
|
return params["sender"], params["text"]
|
|
}
|
|
|
|
t, ok := notifyStrings[locale]
|
|
if !ok {
|
|
t = notifyStrings[DefaultLocale]
|
|
}
|
|
switch typ {
|
|
case KindTransferIncoming:
|
|
return t["incoming.title"], fmt.Sprintf(t["incoming.body"], params["sender"], params["filename"])
|
|
case KindTransferDone:
|
|
return t["done.title"], params["filename"]
|
|
case KindTransferFailed:
|
|
return t["failed.title"], params["filename"]
|
|
case KindSessionRevoked:
|
|
return t["revoked.title"], t["revoked.body"]
|
|
default:
|
|
return params["title"], params["body"]
|
|
}
|
|
}
|