推送通知:Web Push(网页 / PWA)+ 桌面原生通知

页面 / PWA 关闭、或桌面窗口非前台时,收到文件、消息、传输完成 / 失败以系统通知提醒。
判定统一为「页面打开走应用内提示,否则系统通知」,三端各自落地;网页端的判定直接复用
Hub.SendTo 的投递结果,零额外状态。

- Web Push(VAPID,账号无关):服务端 internal/push.Sender 在事件未经 SSE 投递到目标
  设备时(即该设备页面已关)发推送,文案按订阅 locale 在服务端渲染(中简 / 中繁 / 英)、
  404 / 410 自动清理死订阅;新增 push_subscriptions 表(含 locale,主键为 endpoint 的
  SHA-256 以幂等 upsert)+ /api/push/{vapid-key, subscribe POST/DELETE} 端点
- 事件挂接:transfer:incoming / message(离线返 202,文本随推送送达)/ transfer:state
  DONE|FAILED,均未投递才推
- 配置 CDROP_VAPID_PUBLIC_KEY / CDROP_VAPID_PRIVATE_KEY(半对即拒启动,都空则推送惰性
  关闭)+ just vapid-keygen 生成工具(cmd/vapidgen)
- 前端:service worker 加 push / notificationclick(仍不拦截 /api 与导航);net/push.ts
  订阅管理 + push store slice + 设置页「推送通知」面板(仅浏览器 / PWA);登录后 resyncPush
  把既有订阅重新登记到当前用户,换用户 / endpoint 轮换 / 切换语言皆自愈
- 桌面原生通知:platform/notify_{darwin,windows,other}.go + notify_darwin.m,仅窗口非
  前台时弹原生通知(macOS UNUserNotificationCenter,未签名时安全空操作、待签名后显示;
  Windows go-toast 即用);app.go ShowNotification 绑定,前端 notifyNativeIfBackground
  复用 hub 事件分发 + 客户端本地化
- 文档:README / .env.example / compose 模板补 CDROP_VAPID_* 配置

注:sqlc v1.31.1 对 query 文件里的多字节字符会错位偏移、生成损坏 SQL,故 query 注释保持纯
ASCII(文件内留有警示)。
This commit is contained in:
2026-06-18 16:56:47 +08:00
parent e903b03afe
commit 92a03aeafd
40 changed files with 1579 additions and 13 deletions
+37
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
@@ -72,6 +73,15 @@ type Config struct {
// 文本(密码.app / 浏览器扩展复制都不打敏感标记),改以短 TTL 限制暴露面:
// 内容超过 TTL 后 GET 返回空,后台 sweeper 亦清除其 content。0 = 不过期。
ClipboardTTLSec int `koanf:"clipboard_ttl_sec"`
// Web Push (RFC 8291/8292):浏览器 / PWA 在页面关闭时仍能收到系统通知。
// 公私钥是自签的 VAPID 密钥对(与 Apple/Google 账号无关),用 `just vapid-keygen`
// 生成一次后固定——更换会使所有既有订阅失效。两者皆设才启用推送,缺一即整体
// 关闭(订阅端点返回 503,事件回退为「仅在线设备收 SSE」)。Subject 是 VAPID
// 要求的联系标识(mailto: 或站点 URL);留空时回退为部署站点 URL。
VAPIDPublicKey string `koanf:"vapid_public_key"`
VAPIDPrivateKey string `koanf:"vapid_private_key"`
VAPIDSubject string `koanf:"vapid_subject"`
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
@@ -145,5 +155,32 @@ func (c *Config) validate() error {
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
}
// Web Push is optional, but a half-configured pair is always an operator
// mistake: with only one key set, every push send would fail at runtime
// while the feature looks "on". Fail fast instead of silently degrading.
if (c.VAPIDPublicKey == "") != (c.VAPIDPrivateKey == "") {
return errors.New(
"CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " +
"(run `just vapid-keygen`); set neither to disable Web Push")
}
return nil
}
// PushEnabled reports whether Web Push is configured (both VAPID keys present).
func (c *Config) PushEnabled() bool {
return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != ""
}
// VAPIDSubjectOrDefault returns the VAPID contact identity. Push services want a
// way to reach the operator; the configured value wins, else the deployment's
// own origin (derived from the OIDC redirect URI), else the product URL.
func (c *Config) VAPIDSubjectOrDefault() string {
if c.VAPIDSubject != "" {
return c.VAPIDSubject
}
if u, err := url.Parse(c.OIDCRedirectURI); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return "https://drop.commilitia.net"
}
+45
View File
@@ -56,6 +56,51 @@ func TestValidate_ProdRequiresAudience(t *testing.T) {
}
}
func TestValidate_VAPIDHalfPairRejected(t *testing.T) {
// A single VAPID key is always an operator mistake: every push would fail at
// runtime while the feature reads as "on". Reject either half alone.
pubOnly := &Config{AuthMode: "dev", DevToken: "x", VAPIDPublicKey: "pub"}
if err := pubOnly.validate(); err == nil {
t.Fatal("public key without private must reject")
}
privOnly := &Config{AuthMode: "dev", DevToken: "x", VAPIDPrivateKey: "priv"}
if err := privOnly.validate(); err == nil {
t.Fatal("private key without public must reject")
}
}
func TestValidate_VAPIDBothOrNeitherOK(t *testing.T) {
both := &Config{AuthMode: "dev", DevToken: "x", VAPIDPublicKey: "pub", VAPIDPrivateKey: "priv"}
if err := both.validate(); err != nil {
t.Fatalf("both VAPID keys should pass; got %v", err)
}
if !both.PushEnabled() {
t.Fatal("PushEnabled should be true when both keys set")
}
neither := &Config{AuthMode: "dev", DevToken: "x"}
if err := neither.validate(); err != nil {
t.Fatalf("no VAPID keys should pass (push disabled); got %v", err)
}
if neither.PushEnabled() {
t.Fatal("PushEnabled should be false when no keys set")
}
}
func TestVAPIDSubjectOrDefault(t *testing.T) {
explicit := &Config{VAPIDSubject: "mailto:ops@example.com"}
if got := explicit.VAPIDSubjectOrDefault(); got != "mailto:ops@example.com" {
t.Fatalf("explicit subject: got %q", got)
}
derived := &Config{OIDCRedirectURI: "https://drop.example.com/oauth/callback"}
if got := derived.VAPIDSubjectOrDefault(); got != "https://drop.example.com" {
t.Fatalf("derived subject: got %q, want scheme://host", got)
}
fallback := &Config{}
if got := fallback.VAPIDSubjectOrDefault(); !strings.HasPrefix(got, "https://") {
t.Fatalf("fallback subject should be an https URL; got %q", got)
}
}
func TestValidate_UnknownMode(t *testing.T) {
c := &Config{AuthMode: "rogue"}
err := c.validate()
+1 -1
View File
@@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) {
t.Fatalf("bootstrap: %v", err)
}
want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_sessions", "web_sessions"}
want := []string{"clipboard_state", "devices", "push_subscriptions", "shortcut_tokens", "transfer_sessions", "web_sessions"}
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
t.Fatalf("query tables: %v", err)
+24
View File
@@ -67,3 +67,27 @@ CREATE TABLE IF NOT EXISTS web_sessions (
);
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);
-- push_subscriptions:浏览器 / PWA 的 Web Push 订阅端点。当某设备「页面已关闭」
-- (无活的 SSE 连接)时,服务端按此表向其推送系统通知;页面开着时事件仍走 SSE,
-- 前端自弹 toast,不触发 push。id 是 SHA-256(endpoint) 的 hex,使同一浏览器重复
-- 订阅幂等覆盖(endpoint 唯一标识一条推送通道)。p256dh/auth 是 RFC 8291 消息加密
-- 用的客户端公钥与鉴权密钥(base64url)。按 (user_id, device_name) 定位收件设备,
-- 与 hub.SendTo 用 device_name 作 deviceID 一致。桌面端不入此表——它走本地原生通知。
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
device_name TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
-- locale 随订阅存:推送文案在服务端渲染(SW 只负责展示收到的 title/body),
-- 故须知道该设备的界面语言;用户切换语言时前端重新订阅会幂等刷新此列。
locale TEXT NOT NULL DEFAULT 'zh-CN',
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_device
ON push_subscriptions (user_id, device_name);
+13
View File
@@ -21,6 +21,19 @@ type Device struct {
LastSeen int64 `json:"last_seen"`
}
type PushSubscription 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"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
}
type ShortcutToken struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
+146
View File
@@ -0,0 +1,146 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: push_subscriptions.sql
package db
import (
"context"
)
const deletePushSubscription = `-- name: DeletePushSubscription :exec
DELETE FROM push_subscriptions
WHERE id = ?
`
func (q *Queries) DeletePushSubscription(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deletePushSubscription, id)
return err
}
const deletePushSubscriptionsByUserDevice = `-- name: DeletePushSubscriptionsByUserDevice :exec
DELETE FROM push_subscriptions
WHERE user_id = ? AND device_name = ?
`
type DeletePushSubscriptionsByUserDeviceParams struct {
UserID string `json:"user_id"`
DeviceName string `json:"device_name"`
}
func (q *Queries) DeletePushSubscriptionsByUserDevice(ctx context.Context, arg DeletePushSubscriptionsByUserDeviceParams) error {
_, err := q.db.ExecContext(ctx, deletePushSubscriptionsByUserDevice, arg.UserID, arg.DeviceName)
return err
}
const listPushSubscriptionsByUserDevice = `-- name: ListPushSubscriptionsByUserDevice :many
SELECT id, user_id, device_name, endpoint, p256dh, auth, user_agent, locale, created_at, last_used_at
FROM push_subscriptions
WHERE user_id = ? AND device_name = ?
`
type ListPushSubscriptionsByUserDeviceParams 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)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PushSubscription
for rows.Next() {
var i PushSubscription
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.DeviceName,
&i.Endpoint,
&i.P256dh,
&i.Auth,
&i.UserAgent,
&i.Locale,
&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 touchPushSubscription = `-- name: TouchPushSubscription :exec
UPDATE push_subscriptions
SET last_used_at = ?
WHERE id = ?
`
type TouchPushSubscriptionParams struct {
LastUsedAt int64 `json:"last_used_at"`
ID string `json:"id"`
}
func (q *Queries) TouchPushSubscription(ctx context.Context, arg TouchPushSubscriptionParams) error {
_, err := q.db.ExecContext(ctx, touchPushSubscription, arg.LastUsedAt, arg.ID)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
locale = excluded.locale,
last_used_at = excluded.last_used_at
`
type UpsertPushSubscriptionParams 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"`
CreatedAt int64 `json:"created_at"`
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.
// 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 {
_, err := q.db.ExecContext(ctx, upsertPushSubscription,
arg.ID,
arg.UserID,
arg.DeviceName,
arg.Endpoint,
arg.P256dh,
arg.Auth,
arg.UserAgent,
arg.Locale,
arg.CreatedAt,
arg.LastUsedAt,
)
return err
}
@@ -0,0 +1,37 @@
-- 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.
-- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
device_name = excluded.device_name,
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
locale = excluded.locale,
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
FROM push_subscriptions
WHERE user_id = ? AND device_name = ?;
-- name: TouchPushSubscription :exec
UPDATE push_subscriptions
SET last_used_at = ?
WHERE id = ?;
-- name: DeletePushSubscription :exec
DELETE FROM push_subscriptions
WHERE id = ?;
-- name: DeletePushSubscriptionsByUserDevice :exec
DELETE FROM push_subscriptions
WHERE user_id = ? AND device_name = ?;
+16 -1
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -8,6 +9,7 @@ import (
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push"
)
// 4 KB caps DoS-via-paste; longer payloads should use file transfer instead.
@@ -31,7 +33,9 @@ type messageReq struct {
// machine. Receiver sees a `message` event; the frontend keeps it in memory
// only until the tab closes (per user spec).
//
// 204 if delivered, 410 if peer offline, 413 if oversized.
// 204 if delivered live, 202 if the peer is offline but a Web Push was sent
// (the notification carries the text — the message itself is not persisted),
// 410 if offline with no push subscription, 413 if oversized.
func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
from, _ := jwtauth.DeviceNameFromContext(r.Context())
@@ -75,6 +79,17 @@ 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},
})
w.WriteHeader(http.StatusAccepted)
return
}
writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"})
return
}
+119
View File
@@ -0,0 +1,119 @@
package httpapi
import (
"encoding/json"
"net/http"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push"
)
// 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.
const maxPushSubscribeBytes = 8 * 1024
type vapidKeyResp struct {
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key"`
}
// handlePushVAPIDKey hands the browser the VAPID public key it must pass as
// applicationServerKey when calling pushManager.subscribe. The public key is
// not a secret; enabled=false tells the client to hide the push UI entirely.
func (s *Server) handlePushVAPIDKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, vapidKeyResp{
Enabled: s.push.Enabled(),
PublicKey: s.push.PublicKey(),
})
}
// pushSubscribeReq mirrors PushSubscription.toJSON() plus the device's current
// UI locale, so the server can render notification text the device can read.
type pushSubscribeReq struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
Locale string `json:"locale"`
}
// knownLocales gates the locale we persist so a bogus value can't poison the
// stored row; unknown / empty falls back to the server default.
var knownLocales = map[string]bool{"zh-CN": true, "zh-TW": true, "en-US": true}
// handlePushSubscribe stores (or refreshes) this browser's Web Push endpoint for
// the calling device. Keyed by device_name so a notify-worthy event addressed to
// a specific offline device pushes only to that device. Upsert by endpoint hash
// makes a repeat subscribe idempotent.
func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
if !s.push.Enabled() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "push 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, maxPushSubscribeBytes)
var req pushSubscribeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "incomplete subscription"})
return
}
locale := req.Locale
if !knownLocales[locale] {
locale = "zh-CN"
}
now := time.Now().Unix()
if err := s.queries.UpsertPushSubscription(r.Context(), db.UpsertPushSubscriptionParams{
ID: push.SubscriptionID(req.Endpoint),
UserID: claims.UserID,
DeviceName: device,
Endpoint: req.Endpoint,
P256dh: req.Keys.P256dh,
Auth: req.Keys.Auth,
UserAgent: truncate(r.UserAgent(), 256),
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"`
}
// handlePushUnsubscribe drops a subscription the browser has revoked. Deleting by
// endpoint hash needs no auth coupling beyond the session — the worst a wrong
// endpoint does is delete a row the caller doesn't own, and endpoints are
// unguessable capabilities, so this is not a meaningful enumeration vector.
func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscribeBytes)
var req pushUnsubscribeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Endpoint == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing endpoint"})
return
}
if err := s.queries.DeletePushSubscription(r.Context(), push.SubscriptionID(req.Endpoint)); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "delete failed"})
return
}
w.WriteHeader(http.StatusNoContent)
}
+7
View File
@@ -19,6 +19,7 @@ import (
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/push"
"commilitia.net/cdrop/internal/relay"
"commilitia.net/cdrop/internal/transfer"
"commilitia.net/cdrop/internal/webui"
@@ -34,6 +35,7 @@ type Server struct {
relay *relay.Manager
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
mux *chi.Mux
// sessionKey encrypts browser refresh_tokens at rest (web_sessions); nil when
@@ -60,6 +62,7 @@ func New(
rm *relay.Manager,
cp *calls.Provider,
clip *clipboard.Service,
pushSender *push.Sender,
) *Server {
s := &Server{
cfg: cfg,
@@ -71,6 +74,7 @@ func New(
relay: rm,
calls: cp,
clipboard: clip,
push: pushSender,
mux: chi.NewRouter(),
sessionKey: deriveSessionKey(cfg.SessionSecret),
@@ -141,6 +145,9 @@ func (s *Server) routes() {
r.Post("/message", s.handleMessage)
r.Get("/devices", s.handleDevices)
r.Delete("/devices/{name}", s.handleDeleteDevice)
r.Get("/push/vapid-key", s.handlePushVAPIDKey)
r.Post("/push/subscribe", s.handlePushSubscribe)
r.Delete("/push/subscribe", s.handlePushUnsubscribe)
r.Get("/calls/credentials", s.handleCallsCredentials)
r.Post("/shortcut/issue", s.handleShortcutIssue)
r.Get("/shortcut", s.handleShortcutList)
+253
View File
@@ -0,0 +1,253 @@
// 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"
)
// 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.PushSubscription, 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
}
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": "传输失败",
},
"zh-TW": {
"incoming.title": "收到檔案",
"incoming.body": "%s 傳來 %s",
"done.title": "傳輸完成",
"failed.title": "傳輸失敗",
},
"en-US": {
"incoming.title": "Incoming file",
"incoming.body": "%s is sending %s",
"done.title": "Transfer complete",
"failed.title": "Transfer failed",
},
}
// 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
}
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"]
default:
return params["title"], params["body"]
}
}
+117
View File
@@ -0,0 +1,117 @@
package push
import (
"context"
"strings"
"testing"
)
func TestSubscriptionIDDeterministicAndHashed(t *testing.T) {
const endpoint = "https://fcm.googleapis.com/fcm/send/abc123"
id := SubscriptionID(endpoint)
if got := SubscriptionID(endpoint); got != id {
t.Fatalf("SubscriptionID not deterministic: %q vs %q", id, got)
}
if len(id) != 64 {
t.Fatalf("SubscriptionID length = %d, want 64 (hex SHA-256)", len(id))
}
if strings.Contains(id, endpoint) {
t.Fatal("SubscriptionID leaks the endpoint")
}
if SubscriptionID(endpoint+"x") == id {
t.Fatal("distinct endpoints collide")
}
}
func TestNewSenderInertWithoutKeys(t *testing.T) {
s := NewSender(nil, "", "", "mailto:a@b.c")
if s.Enabled() {
t.Fatal("sender with no keys should be disabled")
}
if s.PublicKey() != "" {
t.Fatal("disabled sender should expose no public key")
}
// Notify must be a safe no-op even with nil queries — callers invoke it
// unconditionally and rely on the disabled short-circuit.
s.Notify(context.Background(), "u", "d", Notification{Type: KindMessage})
}
func TestNewSenderEnabledWithKeys(t *testing.T) {
s := NewSender(nil, "pub", "priv", "mailto:a@b.c")
if !s.Enabled() {
t.Fatal("sender with both keys should be enabled")
}
if s.PublicKey() != "pub" {
t.Fatalf("PublicKey = %q, want pub", s.PublicKey())
}
}
func TestNilSenderIsSafe(t *testing.T) {
var s *Sender
if s.Enabled() {
t.Fatal("nil sender should report disabled")
}
if s.PublicKey() != "" {
t.Fatal("nil sender should expose no public key")
}
s.Notify(context.Background(), "u", "d", Notification{}) // must not panic
}
func TestRenderTransferIncoming(t *testing.T) {
n := Notification{
Type: KindTransferIncoming,
Params: map[string]string{"sender": "Mac", "filename": "a.pdf"},
Tag: "transfer:1",
}
en := render(n, "en-US")
if en.Title != "Incoming file" || en.Body != "Mac is sending a.pdf" {
t.Fatalf("en render = %+v", en)
}
if en.Tag != "transfer:1" || en.URL != "/" {
t.Fatalf("tag/url not carried through: %+v", en)
}
if en.Type != KindTransferIncoming {
t.Fatalf("type = %q", en.Type)
}
zh := render(n, "zh-CN")
if zh.Title != "收到文件" || zh.Body != "Mac 发来 a.pdf" {
t.Fatalf("zh-CN render = %+v", zh)
}
tw := render(n, "zh-TW")
if tw.Title != "收到檔案" || tw.Body != "Mac 傳來 a.pdf" {
t.Fatalf("zh-TW render = %+v", tw)
}
}
func TestRenderMessageIsPassthrough(t *testing.T) {
n := Notification{
Type: KindMessage,
Params: map[string]string{"sender": "Mac", "text": "hi there"},
}
// Locale is irrelevant for a message: title is the sender, body the text.
for _, locale := range []string{"en-US", "zh-CN", "zh-TW", "fr-FR"} {
got := render(n, locale)
if got.Title != "Mac" || got.Body != "hi there" {
t.Fatalf("message render(%s) = %+v", locale, got)
}
}
}
func TestRenderUnknownLocaleFallsBackToDefault(t *testing.T) {
n := Notification{
Type: KindTransferDone,
Params: map[string]string{"filename": "a.pdf"},
}
got := render(n, "fr-FR")
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)
}
if got.Title != "传输完成" || got.Body != "a.pdf" {
t.Fatalf("transfer:done render = %+v", got)
}
}
+35 -4
View File
@@ -10,6 +10,7 @@ import (
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/push"
)
var (
@@ -47,12 +48,13 @@ type Service struct {
queries *db.Queries
hub *hub.Hub
relay RelayController
push *push.Sender // inert when Web Push is unconfigured; nil-safe
now func() time.Time
newID func() string
}
func NewService(queries *db.Queries, h *hub.Hub, r RelayController) *Service {
func NewService(queries *db.Queries, h *hub.Hub, r RelayController, pushSender *push.Sender) *Service {
if r == nil {
r = nopRelay{}
}
@@ -60,6 +62,7 @@ func NewService(queries *db.Queries, h *hub.Hub, r RelayController) *Service {
queries: queries,
hub: h,
relay: r,
push: pushSender,
now: time.Now,
newID: defaultNewID,
}
@@ -131,13 +134,22 @@ func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
}
autoAccept := p.FileSize <= AutoAcceptThreshold
s.hub.SendTo(p.UserID, p.ReceiverName, hub.Event{
delivered := s.hub.SendTo(p.UserID, p.ReceiverName, hub.Event{
Type: "transfer:incoming",
Data: map[string]any{
"session": view,
"auto_accept": autoAccept,
},
})
// 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{
Type: push.KindTransferIncoming,
Params: map[string]string{"sender": p.SenderName, "filename": p.FileName},
Tag: "transfer:" + id,
URL: "/",
})
}
return id, nil
}
@@ -203,8 +215,27 @@ func (s *Service) Transition(
Type: "transfer:state",
Data: stateEventPayload(sessionID, target, mode, reason),
}
s.hub.SendTo(userID, sess.SenderName, stateEv)
s.hub.SendTo(userID, sess.ReceiverName, stateEv)
deliveredSender := s.hub.SendTo(userID, sess.SenderName, stateEv)
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) {
kind := push.KindTransferDone
if target == StateFailed {
kind = push.KindTransferFailed
}
n := push.Notification{
Type: kind,
Params: map[string]string{"filename": sess.FileName},
Tag: "transfer:" + sessionID,
}
if !deliveredSender {
go s.push.Notify(context.Background(), userID, sess.SenderName, n)
}
if !deliveredReceiver {
go s.push.Notify(context.Background(), userID, sess.ReceiverName, n)
}
}
if target == StateRelayActive {
readyEv := hub.Event{
+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)
return NewService(q, h, 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)
return NewService(q, h, r, nil)
}
func TestInit_AllowsZeroByteFile(t *testing.T) {