diff --git a/.env.example b/.env.example index dd6a8c3..1e642c4 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,13 @@ CDROP_DEV_TOKEN=replace-with-32-byte-random-base64 # CDROP_HS256_SECRET= # CDROP_TURN_URL=stun:your-stun.example:3478 +# 可选:Web Push(VAPID)推送通知。用 `just vapid-keygen` 生成一对密钥;二者须同时 +# 设置(半对即启动期校验失败),都留空则推送惰性关闭、不阻塞启动。生成后固定不变 +# ——更换会使所有既有浏览器订阅失效。 +# CDROP_VAPID_PUBLIC_KEY= +# CDROP_VAPID_PRIVATE_KEY= +# CDROP_VAPID_SUBJECT=mailto:you@your-domain.example + # 通用 CDROP_DB_PATH=./cdrop.db CDROP_LISTEN=:8080 diff --git a/Justfile b/Justfile index adb143d..440a6cb 100644 --- a/Justfile +++ b/Justfile @@ -30,6 +30,11 @@ fmt: vet: go vet ./... +# 生成一对 VAPID 密钥(Web Push 用)。输出两行 env,写进部署密钥后固定不变—— +# 更换会使所有既有浏览器订阅失效。两者皆设才启用推送。 +vapid-keygen: + go run ./cmd/vapidgen + # ---- desktop client (Wails · macOS 主开发,Windows 在 Parallels 验证) ---- # wails CLI 在 go install 后落在 $(go env GOPATH)/bin/wails, diff --git a/README.md b/README.md index 40d8192..debd3c3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ MVP(M0–M13)已上线 prod(OIDC PKCE 接入自建 Casdoor,Cloudflare Re - **剪贴板同步**:单条覆写式云剪贴板(短 TTL 限暴露面)+ 文本消息一次性通道 - **桌面客户端**:Wails v2(macOS universal `.app` + Windows `.exe`)—— 瘦客户端复用 web,业务全走后端 API;refresh_token 存系统密钥库(信封加密) - **浏览器免重登 + PWA**:可安装为独立 PWA;浏览器侧 refresh_token 不再进 JS,改由服务端 HttpOnly cookie 会话持有(AES-256-GCM 落盘),关闭重开自动免重登(7 天滑动失活),设备名随会话持久化(抗 PWA 存储清除) +- **推送通知**:页面 / PWA 关闭时,收到文件、消息、传输完成或失败以系统通知提醒;页面打开时仍走应用内提示。网页走 Web Push(VAPID,覆盖浏览器 / Android / iOS 16.4+ 已安装 PWA),桌面客户端走原生系统通知(仅窗口非前台才弹)。文案按设备语言在服务端渲染(中简 / 中繁 / 英) - **iOS Shortcut**:HS256 scoped token 手动签发路径(网页签发 UI 暂停) - **安全加固**:中继会话防耗尽、HS256 强制 jti、prod 强制 audience、TURN 短 TTL、OAuth 端点限流(详见 CHANGELOG) @@ -160,4 +161,5 @@ env 见 [`.env.example`](.env.example) / `compose.snippet.yaml`。要点: 4. **`CDROP_SESSION_SECRET` 必须非空**(任意长度高熵串)—— 浏览器免重登会话的 refresh_token 落盘加密密钥(内部 SHA-256 派生 AES-256 密钥);后端 prod 启动期强制此项,缺则拒启动 5. 设 `CDROP_HS256_SECRET`(iOS Shortcut scoped token 用,不用可留空) 6. 可选 `CDROP_CF_TURN_KEY_ID` + `CDROP_CF_TURN_API_TOKEN` 启用 Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底) +7. 可选 `CDROP_VAPID_PUBLIC_KEY` + `CDROP_VAPID_PRIVATE_KEY` 启用 Web Push 推送通知(用 `just vapid-keygen` 生成一对;两者须同时设置,半对则拒启动;都留空则推送惰性关闭,不阻塞启动)。`CDROP_VAPID_SUBJECT` 可选(联系标识,留空回退站点 URL) diff --git a/cmd/cdropd/main.go b/cmd/cdropd/main.go index 2bdbae1..7a63d44 100644 --- a/cmd/cdropd/main.go +++ b/cmd/cdropd/main.go @@ -17,6 +17,7 @@ import ( "commilitia.net/cdrop/internal/httpapi" "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" ) @@ -56,7 +57,13 @@ func main() { h := hub.New(queries) defer h.Close() relayMgr := relay.NewManager(relay.DefaultMaxSessions, relay.DefaultSessionCap) - transfers := transfer.NewService(queries, h, relayMgr) + pushSender := push.NewSender(queries, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubjectOrDefault()) + if pushSender.Enabled() { + slog.Info("web push enabled (VAPID configured)") + } else { + slog.Info("web push disabled (VAPID keys unset)") + } + transfers := transfer.NewService(queries, h, relayMgr, pushSender) var cfTurn *calls.Provider if cfg.CFTurnKeyID != "" && cfg.CFTurnAPIToken != "" { @@ -84,7 +91,7 @@ func main() { srv := &http.Server{ Addr: cfg.Listen, - Handler: httpapi.New(cfg, dbConn, queries, auth, h, transfers, relayMgr, cfTurn, clip).Handler(), + Handler: httpapi.New(cfg, dbConn, queries, auth, h, transfers, relayMgr, cfTurn, clip, pushSender).Handler(), ReadHeaderTimeout: 5 * time.Second, } diff --git a/cmd/vapidgen/main.go b/cmd/vapidgen/main.go new file mode 100644 index 0000000..2cb61be --- /dev/null +++ b/cmd/vapidgen/main.go @@ -0,0 +1,23 @@ +// Command vapidgen prints a fresh VAPID key pair for Web Push, formatted as the +// two env vars cdrop reads. Generate once and store the keys as deployment +// secrets — rotating them invalidates every existing browser subscription. +// +// just vapid-keygen # or: go run ./cmd/vapidgen +package main + +import ( + "fmt" + "os" + + webpush "github.com/SherClockHolmes/webpush-go" +) + +func main() { + priv, pub, err := webpush.GenerateVAPIDKeys() + if err != nil { + fmt.Fprintln(os.Stderr, "vapidgen: generate keys:", err) + os.Exit(1) + } + fmt.Println("CDROP_VAPID_PUBLIC_KEY=" + pub) + fmt.Println("CDROP_VAPID_PRIVATE_KEY=" + priv) +} diff --git a/desktop/app.go b/desktop/app.go index 50d02c7..58ba6f7 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -195,6 +195,18 @@ func (a *App) ApplyRemoteClipboard(content, sourceDevice string) { }, a.DeviceName()) } +// ShowNotification is bound to the WebView. The JS hub calls it when a +// notify-worthy event (incoming file / message / transfer done|failed) arrives +// while the window is NOT in the foreground — so the user gets a native system +// notification instead of an in-app cue they'd miss. Foreground events stay +// in-app: the JS gates on document focus/visibility, so this is only ever +// invoked for the background case. Title/body arrive already localized by the +// WebView (client-side i18n). macOS display additionally needs a signed bundle +// (D6) — until then platform.Notify no-ops safely there; Windows shows now. +func (a *App) ShowNotification(title, body string) { + platform.Notify(title, body) +} + // StartLogin is bound to the WebView. It runs the loopback PKCE flow in the // background and reports the outcome via Wails events ("oauth:success" / // "oauth:error"). On success it persists the full session (with refresh_token) diff --git a/desktop/go.mod b/desktop/go.mod index 34e5537..f38fa6e 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -4,6 +4,7 @@ go 1.24 require ( fyne.io/systray v1.12.2 + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/ebitengine/purego v0.10.1 github.com/wailsapp/wails/v2 v2.12.0 github.com/zalando/go-keyring v0.2.8 @@ -12,7 +13,6 @@ require ( ) require ( - git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/go-ole/go-ole v1.3.0 // indirect diff --git a/desktop/platform/notify_darwin.go b/desktop/platform/notify_darwin.go new file mode 100644 index 0000000..7beae65 --- /dev/null +++ b/desktop/platform/notify_darwin.go @@ -0,0 +1,31 @@ +//go:build darwin + +package platform + +/* +#cgo darwin CFLAGS: -x objective-c -fobjc-arc +#cgo darwin LDFLAGS: -framework Foundation -framework UserNotifications +#include + +void cdropNotify(const char *title, const char *body); +*/ +import "C" + +import "unsafe" + +// Notify shows a native system notification. +// +// macOS uses UNUserNotificationCenter, which REQUIRES the app bundle to be +// code-signed with a valid bundle identifier (see desktop/PLAN.md D3/D6). On an +// unsigned / ad-hoc build the ObjC side detects the missing bundle proxy and +// no-ops rather than crashing — so this is safe to call from the current build; +// it simply won't display until the signing + notarization pipeline (D6) ships. +// +// Safe to call from any goroutine: the native work hops to the main queue. +func Notify(title, body string) { + ct := C.CString(title) + cb := C.CString(body) + defer C.free(unsafe.Pointer(ct)) + defer C.free(unsafe.Pointer(cb)) + C.cdropNotify(ct, cb) +} diff --git a/desktop/platform/notify_darwin.m b/desktop/platform/notify_darwin.m new file mode 100644 index 0000000..4ab6642 --- /dev/null +++ b/desktop/platform/notify_darwin.m @@ -0,0 +1,54 @@ +//go:build darwin + +#import +#import + +// cdropNotify posts a user notification via UNUserNotificationCenter. +// +// Guarded for the unsigned case: UNUserNotificationCenter raises +// NSInternalInconsistencyException ("bundleProxyForCurrentProcess is nil") when +// the process has no signed bundle proxy. We bail when there's no bundle +// identifier and wrap the calls in @try/@catch, so an unsigned / ad-hoc build +// degrades to a silent no-op instead of crashing the app. On a signed build it +// requests authorization once (idempotent after the first grant) and delivers. +void cdropNotify(const char *title, const char *body) { + if (title == NULL || body == NULL) { + return; + } + NSString *nsTitle = [NSString stringWithUTF8String:title]; + NSString *nsBody = [NSString stringWithUTF8String:body]; + + // No bundle identifier → unsigned / non-bundle build → UNUserNotificationCenter + // would throw. Skip; display lights up once the bundle is signed (D6). + if ([[NSBundle mainBundle] bundleIdentifier] == nil) { + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + // UNUserNotificationCenter is macOS 10.14+. Guard so older targets (Wails + // defaults the deployment target to 10.13) compile clean and no-op there. + if (@available(macOS 10.14, *)) { + @try { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound) + completionHandler:^(BOOL granted, NSError *_Nullable error) { + if (!granted) { + return; + } + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.title = nsTitle; + content.body = nsBody; + content.sound = [UNNotificationSound defaultSound]; + + UNNotificationRequest *request = + [UNNotificationRequest requestWithIdentifier:[[NSUUID UUID] UUIDString] + content:content + trigger:nil]; + [center addNotificationRequest:request withCompletionHandler:nil]; + }]; + } @catch (NSException *exception) { + // Unsigned bundle or notifications unavailable — ignore. + } + } + }); +} diff --git a/desktop/platform/notify_other.go b/desktop/platform/notify_other.go new file mode 100644 index 0000000..d528939 --- /dev/null +++ b/desktop/platform/notify_other.go @@ -0,0 +1,8 @@ +//go:build !darwin && !windows + +package platform + +// Notify is a no-op on platforms without a native notification backend wired up. +// The desktop client targets macOS and Windows; this keeps the package building +// on other GOOS (e.g. a Linux `go vet` / CI pass). +func Notify(_, _ string) {} diff --git a/desktop/platform/notify_windows.go b/desktop/platform/notify_windows.go new file mode 100644 index 0000000..e18bb11 --- /dev/null +++ b/desktop/platform/notify_windows.go @@ -0,0 +1,42 @@ +//go:build windows + +package platform + +import ( + "os" + "sync" + + toast "git.sr.ht/~jackmordaunt/go-toast/v2" +) + +const ( + // toastAppID 是 Windows Action Center 里显示的应用标识,与 macOS bundle id 对齐。 + toastAppID = "net.commilitia.cdrop" + // toastGUID 固定不变——它把通知归属到注册表里的本应用条目;更换会让既有通知 + // 失去归属。 + toastGUID = "{c4d8e2a1-6b3f-4e7a-9c2d-1f5b8a0e3d6c}" +) + +var toastInit sync.Once + +// Notify shows a native Windows toast. go-toast renders via the WinRT/COM path +// (PowerShell fallback when the AppID isn't registered), so it works without code +// signing — SmartScreen only gates the installer, not notifications. Best-effort: +// errors are swallowed. SetAppData registers the app identity once so the toast +// shows "cdrop" rather than the PowerShell host. +func Notify(title, body string) { + toastInit.Do(func() { + data := toast.AppData{AppID: toastAppID, GUID: toastGUID} + if exe, err := os.Executable(); err == nil { + data.ActivationExe = exe + } + _ = toast.SetAppData(data) + }) + + n := toast.Notification{ + AppID: toastAppID, + Title: title, + Body: body, + } + _ = n.Push() +} diff --git a/docker/compose.snippet.yaml b/docker/compose.snippet.yaml index 4348d1f..eb46c8f 100644 --- a/docker/compose.snippet.yaml +++ b/docker/compose.snippet.yaml @@ -35,6 +35,11 @@ NN-cdrop: # CDROP_SESSION_SECRET: REPLACE_WITH_RANDOM_HEX64 # CDROP_HS256_SECRET: REPLACE_WITH_RANDOM_HEX64 + # ---- 可选:Web Push(VAPID)推送通知(不配则推送惰性关闭)---- + # 用 `just vapid-keygen` 生成一对,二者须同时设置(半对即拒启动) + # CDROP_VAPID_PUBLIC_KEY: + # CDROP_VAPID_PRIVATE_KEY: + # ---- 可选:Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)---- # CDROP_CF_TURN_KEY_ID: # CDROP_CF_TURN_API_TOKEN: <同 App 派发的 API Token> diff --git a/go.mod b/go.mod index d510298..5222c00 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module commilitia.net/cdrop go 1.26.2 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/httprate v0.15.0 github.com/go-jose/go-jose/v4 v4.1.4 @@ -17,6 +18,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect @@ -27,6 +29,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 848bada..2573d5e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -12,6 +14,9 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -48,21 +53,85 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/config/config.go b/internal/config/config.go index c78921e..5da5a64 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1b02d95..0da545e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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() diff --git a/internal/db/bootstrap_test.go b/internal/db/bootstrap_test.go index 06b7d6c..4d5cc0d 100644 --- a/internal/db/bootstrap_test.go +++ b/internal/db/bootstrap_test.go @@ -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) diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql index 3c09320..e953c6d 100644 --- a/internal/db/migrations/0001_init.sql +++ b/internal/db/migrations/0001_init.sql @@ -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); diff --git a/internal/db/models.go b/internal/db/models.go index 1c0f05b..0d52690 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -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"` diff --git a/internal/db/push_subscriptions.sql.go b/internal/db/push_subscriptions.sql.go new file mode 100644 index 0000000..e9898f9 --- /dev/null +++ b/internal/db/push_subscriptions.sql.go @@ -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 +} diff --git a/internal/db/queries/push_subscriptions.sql b/internal/db/queries/push_subscriptions.sql new file mode 100644 index 0000000..c02d0c9 --- /dev/null +++ b/internal/db/queries/push_subscriptions.sql @@ -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 = ?; diff --git a/internal/httpapi/message.go b/internal/httpapi/message.go index afc2a27..9cf626d 100644 --- a/internal/httpapi/message.go +++ b/internal/httpapi/message.go @@ -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 } diff --git a/internal/httpapi/push.go b/internal/httpapi/push.go new file mode 100644 index 0000000..a069973 --- /dev/null +++ b/internal/httpapi/push.go @@ -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) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 11aa777..7f4e059 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) diff --git a/internal/push/sender.go b/internal/push/sender.go new file mode 100644 index 0000000..b9077b9 --- /dev/null +++ b/internal/push/sender.go @@ -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"] + } +} diff --git a/internal/push/sender_test.go b/internal/push/sender_test.go new file mode 100644 index 0000000..4908f0e --- /dev/null +++ b/internal/push/sender_test.go @@ -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) + } +} diff --git a/internal/transfer/service.go b/internal/transfer/service.go index c426aa5..7d722a6 100644 --- a/internal/transfer/service.go +++ b/internal/transfer/service.go @@ -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{ diff --git a/internal/transfer/service_test.go b/internal/transfer/service_test.go index dcc2ea8..572bf07 100644 --- a/internal/transfer/service_test.go +++ b/internal/transfer/service_test.go @@ -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) { diff --git a/web/public/sw.js b/web/public/sw.js index 84514ad..151f5fc 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -60,3 +60,43 @@ self.addEventListener("fetch", (event) => }), ); }); + +// ---- Web Push -------------------------------------------------------------- +// Fires only when this device's page is closed (an open page got the event over +// SSE and showed an in-app toast instead). The payload is the JSON the server's +// push.Sender already localized: { type, title, body, tag, url }. The SW just +// displays it — no /api access, no app logic. +self.addEventListener("push", (event) => +{ + let data = {}; + try { data = event.data ? event.data.json() : {}; } + catch (_e) { data = {}; } + + const title = data.title || "cdrop"; + const options = { + body: data.body || "", + tag: data.tag || undefined, // same tag replaces a stale notification in place + icon: "/icon-192.png", + badge: "/icon-192.png", + data: { url: data.url || "/" }, + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// Clicking a notification focuses an existing window if one is open, else opens +// the app at the notification's target URL. +self.addEventListener("notificationclick", (event) => +{ + event.notification.close(); + const target = (event.notification.data && event.notification.data.url) || "/"; + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => + { + for (const c of clients) + { + if ("focus" in c) { return c.focus(); } + } + return self.clients.openWindow ? self.clients.openWindow(target) : undefined; + }), + ); +}); diff --git a/web/src/features/transfer/hub.ts b/web/src/features/transfer/hub.ts index 107f850..10009c0 100644 --- a/web/src/features/transfer/hub.ts +++ b/web/src/features/transfer/hub.ts @@ -1,6 +1,8 @@ import { connectSSE, type SseEvent } from "../../net/sse"; import { useAppStore, type DeviceInfo, type TransferRecord } from "../../store"; import { apiFetch } from "../../net/api"; +import { notifyNativeIfBackground } from "../../net/desktop"; +import { t } from "../../i18n"; import { p2pHandleSignal, p2pCleanup } from "./p2p"; import { rememberIncoming, handleRelayReady, cleanupTransfer } from "./transfer"; import { applyRemoteClipboard } from "../clipboard/clipboard"; @@ -156,6 +158,8 @@ function handleIncomingMessage(data: unknown): void text: p.text, sentAt: typeof p.sent_at === "number" ? p.sent_at : Math.floor(Date.now() / 1000), }); + // 桌面端窗口在后台时弹原生通知(标题=发送方设备名,正文=消息)。 + notifyNativeIfBackground(p.from, p.text); } function handleClipboardUpdate(data: unknown): void @@ -256,6 +260,12 @@ async function handleIncoming(data: unknown): Promise }; useAppStore.getState().upsertTransfer(rec); + // 桌面端窗口在后台时弹原生通知「收到文件」(前台时由传输卡片呈现)。 + notifyNativeIfBackground( + t("notify.incoming.title"), + t("notify.incoming.body", { sender: s.sender_name, file: s.file_name }), + ); + // brief §2 写明 "> 100 MB 二次确认",但 MVP 没有该 UI,且 auto_accept=false // 时跳过 rememberIncoming + /accept 会让 sender 等不到任何 ICE 候选 → 30s // 后 fallback 到 relay → ring buffer 64 MiB 写满 → 整个传输死锁。所以这里 @@ -297,6 +307,14 @@ function handleState(data: unknown): void store.upsertTransfer({ ...cur, mode: p.mode }); } store.completeTransfer(p.session_id, p.state); + // 桌面端窗口在后台时,对完成 / 失败弹原生通知(取消不提醒,与推送事件选择一致)。 + if (p.state === "DONE" || p.state === "FAILED") + { + notifyNativeIfBackground( + t(p.state === "DONE" ? "notify.transfer.doneTitle" : "notify.transfer.failedTitle"), + cur?.fileName ?? "", + ); + } return; } if (cur) diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index d0557e5..25c9e73 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -117,6 +117,20 @@ export const enUS: Partial = { "settings.offline": "Offline", "settings.lastSeen": "Last seen {{time}}", "settings.lastSeenNever": "Never connected", + "settings.push.title": "Push notifications", + "settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.", + "settings.push.enable": "Enable push", + "settings.push.enabled": "Enabled", + "settings.push.disable": "Turn off", + "settings.push.deniedHint": "Notifications are blocked by the browser. Allow them in this site's settings, then try again.", + "settings.push.denied": "Notification permission denied", + "settings.push.enableOk": "Push notifications enabled", + "settings.push.disableOk": "Push notifications turned off", + "settings.push.failed": "Couldn't enable push. Please try again.", + "notify.incoming.title": "Incoming file", + "notify.incoming.body": "{{sender}} is sending {{file}}", + "notify.transfer.doneTitle": "Transfer complete", + "notify.transfer.failedTitle": "Transfer failed", "settings.account.title": "Account", "settings.account.signOut": "Sign out", "settings.desktop.title": "Desktop", diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 23ebde4..de1f286 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -114,6 +114,20 @@ export const zhCN = { "settings.offline": "离线", "settings.lastSeen": "上次活跃 {{time}}", "settings.lastSeenNever": "尚未上线", + "settings.push.title": "推送通知", + "settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。", + "settings.push.enable": "启用推送", + "settings.push.enabled": "已启用", + "settings.push.disable": "关闭推送", + "settings.push.deniedHint": "通知权限已被浏览器拒绝,请在浏览器的站点设置中允许后重试。", + "settings.push.denied": "通知权限被拒绝", + "settings.push.enableOk": "已启用推送通知", + "settings.push.disableOk": "已关闭推送通知", + "settings.push.failed": "启用推送失败,请重试", + "notify.incoming.title": "收到文件", + "notify.incoming.body": "{{sender}} 发来 {{file}}", + "notify.transfer.doneTitle": "传输完成", + "notify.transfer.failedTitle": "传输失败", "settings.account.title": "账号", "settings.account.signOut": "退出登录", "settings.desktop.title": "桌面", diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts index 6a2816e..52aba40 100644 --- a/web/src/i18n/locales/zh-TW.ts +++ b/web/src/i18n/locales/zh-TW.ts @@ -118,6 +118,20 @@ export const zhTW: Partial = { "settings.offline": "離線", "settings.lastSeen": "上次活躍 {{time}}", "settings.lastSeenNever": "尚未上線", + "settings.push.title": "推播通知", + "settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。", + "settings.push.enable": "啟用推播", + "settings.push.enabled": "已啟用", + "settings.push.disable": "關閉推播", + "settings.push.deniedHint": "通知權限已被瀏覽器拒絕,請在瀏覽器的網站設定中允許後重試。", + "settings.push.denied": "通知權限被拒絕", + "settings.push.enableOk": "已啟用推播通知", + "settings.push.disableOk": "已關閉推播通知", + "settings.push.failed": "啟用推播失敗,請重試", + "notify.incoming.title": "收到檔案", + "notify.incoming.body": "{{sender}} 傳來 {{file}}", + "notify.transfer.doneTitle": "傳輸完成", + "notify.transfer.failedTitle": "傳輸失敗", "settings.account.title": "帳號", "settings.account.signOut": "登出", "settings.desktop.title": "桌面", diff --git a/web/src/net/desktop.ts b/web/src/net/desktop.ts index 942d7b4..99303fe 100644 --- a/web/src/net/desktop.ts +++ b/web/src/net/desktop.ts @@ -43,6 +43,9 @@ interface DesktopBridge SaveDownload: (name: string, data: string) => Promise; ChooseDownloadDir: (title: string) => Promise; EffectiveDownloadDir: () => Promise; + // 弹原生系统通知(macOS UNUserNotificationCenter / Windows toast)。标题、正文 + // 已由前端本地化。 + ShowNotification: (title: string, body: string) => Promise; } // 桌面剪贴板自动同步开关,默认开;由设置页通过 setClipboardSyncEnabled 调整。 @@ -137,6 +140,21 @@ export async function ensureDeviceName(): Promise catch { /* 保留为空,回退到 /setup 手动命名 */ } } +// notifyNativeIfBackground:桌面端窗口非前台时,弹一条原生系统通知。窗口在前台 +// (可见且聚焦)时为 no-op——此刻应用内 UI 已呈现该事件,符合「页面打开用应用内 +// 提示,否则系统通知」。浏览器里 isDesktop()=false,始终 no-op(Web 的后台通知走 +// 服务端 Web Push)。title / body 须由调用方先用 t() 本地化。 +export function notifyNativeIfBackground(title: string, body: string): void +{ + const app = bridge(); + if (!isDesktop() || !app) { return; } + const foreground = typeof document !== "undefined" + && document.visibilityState === "visible" + && document.hasFocus(); + if (foreground) { return; } + void app.ShowNotification(title, body).catch(() => { /* 通知失败不影响主流程 */ }); +} + // initDesktopMenuBridge 把 Go 菜单栏发来的事件接到前端(如「设置」→ 路由跳转)。 // 仅桌面端生效;浏览器里 isDesktop()=false 直接返回,对 Web 零影响。 export function initDesktopMenuBridge(onSettings: () => void): void diff --git a/web/src/net/push.ts b/web/src/net/push.ts new file mode 100644 index 0000000..79fa061 --- /dev/null +++ b/web/src/net/push.ts @@ -0,0 +1,207 @@ +import { apiFetch } from "./api"; +import { isDesktop } from "./desktop"; +import { getLocale } from "../i18n"; +import { useAppStore } from "../store"; + +// Web Push 订阅管理。仅浏览器 / PWA——桌面壳走原生通知,不在此列。 +// +// 分工:「页面打开→应用内 toast,页面关闭→系统通知」的判定在服务端(Hub.SendTo +// 投递失败即推 Web Push,见后端 internal/push)。前端这里只做三件事:把本设备的 +// 推送端点登记到服务端(按设备名定位收件设备)、开关订阅、登录后重新登记。 + +interface VapidKeyResp +{ + enabled: boolean; + public_key: string; +} + +export interface PushState +{ + supported: boolean; + permission: NotificationPermission; // "default" | "granted" | "denied" + subscribed: boolean; // 浏览器已持有 pushManager 订阅 +} + +// pushSupported:需 SW + PushManager + Notification 三者俱全、非桌面壳、且为 prod +// 构建(SW 仅在 prod 注册,见 main.tsx;dev 下无 SW,serviceWorker.ready 永不 +// resolve)。任一不满足即视为不支持,设置页隐藏入口。 +export function pushSupported(): boolean +{ + return import.meta.env.PROD + && !isDesktop() + && typeof navigator !== "undefined" + && "serviceWorker" in navigator + && typeof window !== "undefined" + && "PushManager" in window + && "Notification" in window; +} + +function permissionNow(): NotificationPermission +{ + return typeof Notification !== "undefined" ? Notification.permission : "default"; +} + +async function registration(): Promise +{ + if (!pushSupported()) { return null; } + try { return await navigator.serviceWorker.ready; } + catch { return null; } +} + +async function existingSubscription(): Promise +{ + const reg = await registration(); + if (!reg) { return null; } + try { return await reg.pushManager.getSubscription(); } + catch { return null; } +} + +// currentPushState:供设置页初始化展示当前订阅 / 权限状态。 +export async function currentPushState(): Promise +{ + if (!pushSupported()) + { + return { supported: false, permission: "default", subscribed: false }; + } + const sub = await existingSubscription(); + return { supported: true, permission: permissionNow(), subscribed: sub !== null }; +} + +async function fetchVapidKey(): Promise +{ + try + { + const r = await apiFetch("/api/push/vapid-key"); + if (!r.ok) { return null; } + return await r.json() as VapidKeyResp; + } + catch { return null; } +} + +// postSubscription:把浏览器订阅 + 当前 locale 登记到服务端(apiFetch 带上 +// X-Device-Name,服务端按设备名归属)。服务端以 endpoint 的哈希为主键 upsert, +// 故重复登记 / 换用户 / locale 变更都幂等覆盖。 +async function postSubscription(sub: PushSubscription): Promise +{ + const json = sub.toJSON(); + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { return false; } + try + { + const r = await apiFetch("/api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: json.endpoint, + keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, + locale: getLocale(), + }), + }); + return r.ok; + } + catch { return false; } +} + +async function deleteServerSubscription(endpoint: string): Promise +{ + try + { + await apiFetch("/api/push/subscribe", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + } + catch { /* ignore:服务端订阅清理失败只是体验降级 */ } +} + +// enablePush:用户在设置里开启。请求通知权限 → 创建(或复用)浏览器订阅 → 登记 +// 服务端。返回最终状态;权限被拒 / 服务端未配置 VAPID / 订阅失败时 subscribed=false。 +export async function enablePush(): Promise +{ + if (!pushSupported()) + { + return { supported: false, permission: "default", subscribed: false }; + } + + let perm = permissionNow(); + if (perm === "default") + { + try { perm = await Notification.requestPermission(); } + catch { perm = permissionNow(); } + } + useAppStore.getState().setPushPermission(perm); + if (perm !== "granted") + { + return { supported: true, permission: perm, subscribed: false }; + } + + const vapid = await fetchVapidKey(); + if (!vapid?.enabled || !vapid.public_key) + { + return { supported: true, permission: perm, subscribed: false }; + } + + const reg = await registration(); + if (!reg) { return { supported: true, permission: perm, subscribed: false }; } + + let sub = await reg.pushManager.getSubscription(); + if (!sub) + { + try + { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapid.public_key), + }); + } + catch + { + return { supported: true, permission: perm, subscribed: false }; + } + } + + const ok = await postSubscription(sub); + useAppStore.getState().setPushSubscribed(ok); + return { supported: true, permission: perm, subscribed: ok }; +} + +// disablePush:用户在设置里关闭。删服务端 + 退订浏览器,使再次登录的 resyncPush +// 不会自动恢复(浏览器无订阅即视为用户不想要)。 +export async function disablePush(): Promise +{ + const sub = await existingSubscription(); + if (sub) + { + await deleteServerSubscription(sub.endpoint); + try { await sub.unsubscribe(); } + catch { /* ignore */ } + } + useAppStore.getState().setPushSubscribed(false); + return { supported: pushSupported(), permission: permissionNow(), subscribed: false }; +} + +// resyncPush:登录后(root effect 内)调用。若浏览器已有订阅且权限在握,把它重新 +// 登记到当前用户名下——让「同一浏览器换用户登录」「endpoint 轮换」「切换 locale」 +// 自愈,且无需再弹权限。无订阅则不动(用户从未开启推送)。fire-and-forget。 +export async function resyncPush(): Promise +{ + if (!pushSupported() || permissionNow() !== "granted") { return; } + const sub = await existingSubscription(); + if (!sub) { return; } + const ok = await postSubscription(sub); + useAppStore.getState().setPushSubscribed(ok); +} + +// urlBase64ToUint8Array:把 VAPID base64url 公钥转成 applicationServerKey 需要的 +// 字节数组(标准 Web Push 样板)。 +function urlBase64ToUint8Array(base64: string): Uint8Array +{ + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(b64); + // Back the view with a concrete ArrayBuffer (not ArrayBufferLike) so it + // satisfies BufferSource for applicationServerKey under TS 5.7 strict typing. + const out = new Uint8Array(new ArrayBuffer(raw.length)); + for (let i = 0; i < raw.length; i += 1) { out[i] = raw.charCodeAt(i); } + return out; +} diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 4eb2d9f..420a232 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -15,6 +15,7 @@ import { logout } from "../features/auth/auth"; import { uploadClipboard } from "../features/clipboard/clipboard"; import { onNativeClipboard } from "../net/desktop"; import { startHub } from "../features/transfer/hub"; +import { resyncPush } from "../net/push"; import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; import { t, type Locale } from "../i18n"; import { Callout, IconButton, Tooltip } from "../ui/primitives"; @@ -47,6 +48,9 @@ function RootLayout() startHub(ctrl.signal); // 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。 void refreshICEServers(); + // 已开启推送的浏览器:登录后把订阅重新登记到当前用户名下(自愈换用户 / + // endpoint 轮换 / locale 变更);未开启或桌面端为 no-op。 + void resyncPush(); // 桌面:本机复制 → 上传云端(复用 uploadClipboard 链路;浏览器里为 no-op)。 // 上传失败静默——不打断用户,下次复制再试。 const offNativeClipboard = onNativeClipboard((text) => diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index 9f00879..e53fecb 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Anchor, Container, @@ -8,11 +8,18 @@ import { Title, } from "@mantine/core"; import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router"; -import { LogOut, Trash2 } from "lucide-react"; +import { Bell, LogOut, Trash2 } from "lucide-react"; import { logout, syncWebDeviceName } from "../features/auth/auth"; import { DesktopSettings } from "../features/desktop/DesktopSettings"; import { isDesktop, persistDesktopDeviceName } from "../net/desktop"; import { apiFetch } from "../net/api"; +import { + currentPushState, + disablePush, + enablePush, + pushSupported, + type PushState, +} from "../net/push"; import { useAppStore, type DeviceInfo } from "../store"; import { t } from "../i18n"; import { formatRelative, isAsciiDeviceName } from "../utils/format"; @@ -236,6 +243,8 @@ function SettingsPage() {isDesktop() && } + {pushSupported() && } + {peerDevices.length === 0 ? ( {t("settings.peers.empty")} @@ -279,6 +288,89 @@ function SettingsPage() ); } +// PushPanel:浏览器 / PWA 的 Web Push 开关。仅 pushSupported() 为真时渲染(桌面壳 +// 与 dev 均为 false——桌面走原生通知、dev 无 SW)。订阅权威态在浏览器 pushManager, +// 挂载时用 currentPushState 读取一次。 +function PushPanel() +{ + const [ state, setState ] = useState(null); + const [ busy, setBusy ] = useState(false); + + useEffect(() => + { + let alive = true; + void currentPushState().then((s) => { if (alive) { setState(s); } }); + return () => { alive = false; }; + }, []); + + const handleEnable = async () => + { + setBusy(true); + try + { + const s = await enablePush(); + setState(s); + if (s.subscribed) { toast.ok(t("settings.push.enableOk")); } + else if (s.permission === "denied") { toast.warn(t("settings.push.denied")); } + else { toast.error(t("settings.push.failed")); } + } + finally { setBusy(false); } + }; + + const handleDisable = async () => + { + setBusy(true); + try + { + const s = await disablePush(); + setState(s); + toast.ok(t("settings.push.disableOk")); + } + finally { setBusy(false); } + }; + + const subscribed = state?.subscribed ?? false; + const denied = state?.permission === "denied"; + + return ( + + + {t("settings.push.hint")} + {denied && !subscribed && ( + + {t("settings.push.deniedHint")} + + )} + + {subscribed ? ( + + ) : ( + + )} + {subscribed && ( + {t("settings.push.enabled")} + )} + + + + ); +} + function PeerRow(props: { device: DeviceInfo; removing: boolean; onRemove: () => void }) { const { device, removing, onRemove } = props; diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 95f8d9a..76f5abd 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -8,6 +8,7 @@ import { createClipboardSlice } from "./slices/clipboard"; import { createUiSlice } from "./slices/ui"; import { createThemeSlice } from "./slices/theme"; import { createLocaleSlice } from "./slices/locale"; +import { createPushSlice } from "./slices/push"; export const useAppStore = create()((...a) => ({ @@ -19,6 +20,7 @@ export const useAppStore = create()((...a) => ...createUiSlice(...a), ...createThemeSlice(...a), ...createLocaleSlice(...a), + ...createPushSlice(...a), })); // 重新导出所有 type 与 helper,保证现有 `from "./store"` import 仍可解析。 diff --git a/web/src/store/slices/push.ts b/web/src/store/slices/push.ts new file mode 100644 index 0000000..3083bac --- /dev/null +++ b/web/src/store/slices/push.ts @@ -0,0 +1,23 @@ +import type { StateCreator } from "zustand"; +import type { AppState } from "../types"; + +export type PushSlice = Pick< + AppState, + "pushPermission" | "pushSubscribed" | "setPushPermission" | "setPushSubscribed" +>; + +function initialPermission(): NotificationPermission +{ + return typeof Notification !== "undefined" ? Notification.permission : "default"; +} + +// Web Push 的 UI 状态。订阅的权威来源是浏览器 pushManager(currentPushState 据此 +// 读出),这里只是为设置页提供响应式镜像,避免每次都 await 异步查询。 +export const createPushSlice: StateCreator = (set) => +({ + pushPermission: initialPermission(), + pushSubscribed: false, + + setPushPermission: (p) => { set({ pushPermission: p }); }, + setPushSubscribed: (b) => { set({ pushSubscribed: b }); }, +}); diff --git a/web/src/store/types.ts b/web/src/store/types.ts index 7aadbcb..3c7dce1 100644 --- a/web/src/store/types.ts +++ b/web/src/store/types.ts @@ -166,4 +166,12 @@ export interface AppState // ---- locale slice ---- locale: Locale; setLocale: (l: Locale) => void; + + // ---- push slice ---- + // Web Push 的响应式镜像(权威态在浏览器 pushManager)。仅浏览器 / PWA 有意义, + // 桌面壳走原生通知不读这两项。 + pushPermission: NotificationPermission; + pushSubscribed: boolean; + setPushPermission: (p: NotificationPermission) => void; + setPushSubscribed: (b: boolean) => void; }