// 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"] } }