Files
Commilitia-Drop/internal/httpapi/message.go
T
admin 92a03aeafd 推送通知: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(文件内留有警示)。
2026-06-18 16:56:47 +08:00

98 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"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.
const maxMessageBytes = 4 * 1024
// maxMessageBodyBytes bounds the raw request body BEFORE decode (R5): the 4 KB
// limit above checks the decoded Go string, so on its own the whole body is
// still read into memory first. The wire form can be larger than the decoded
// text — JSON escaping inflates quotes/control chars (worst case ~6×) — so the
// body cap is the content cap with headroom, not equal to it, to avoid falsely
// rejecting a legitimate 4 KB message while still bounding memory per request.
const maxMessageBodyBytes = maxMessageBytes * 8
type messageReq struct {
To string `json:"to"`
Text string `json:"text"`
}
// handleMessage forwards a one-shot text payload to a peer device. Reuses the
// SSE Hub the same way /api/hub/signal does — no DB row, no transfer state
// machine. Receiver sees a `message` event; the frontend keeps it in memory
// only until the tab closes (per user spec).
//
// 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())
r.Body = http.MaxBytesReader(w, r.Body, maxMessageBodyBytes)
var req messageReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "message exceeds 4 KB"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.To == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to'"})
return
}
if req.Text == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty text"})
return
}
if len(req.Text) > maxMessageBytes {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "message exceeds 4 KB"})
return
}
if req.To == from {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot send to self"})
return
}
delivered := s.hub.SendTo(claims.UserID, req.To, hub.Event{
Type: "message",
Data: map[string]any{
"from": from,
"text": req.Text,
"sent_at": time.Now().Unix(),
},
})
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
}
w.WriteHeader(http.StatusNoContent)
}