推送通知: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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 = ?;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user