Files
Commilitia-Drop/internal/hub/hub.go
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

243 lines
5.9 KiB
Go

package hub
import (
"context"
"log/slog"
"sync"
"time"
"commilitia.net/cdrop/internal/db"
)
// DeviceLister is the subset of *db.Queries the hub needs.
// Declared as interface so tests can inject a fake.
type DeviceLister interface {
ListDevicesByUser(ctx context.Context, userID string) ([]db.Device, error)
}
// Event is what the hub fan-outs to SSE clients.
type Event struct {
Type string `json:"-"`
Data any `json:"-"`
}
// PresenceDevice is the per-device entry in a `presence` event payload.
// Field names align with brief §5: { name, type, online }; last_seen 是
// 设置页展示「上次活跃」需要的扩展字段。
type PresenceDevice struct {
Name string `json:"name"`
Type string `json:"type"`
Online bool `json:"online"`
LastSeen int64 `json:"last_seen"`
}
const clientBuffer = 32
// Client is a single live SSE connection.
type Client struct {
UserID string
DeviceID string
ch chan Event
}
func (c *Client) Events() <-chan Event { return c.ch }
// Hub is the in-memory presence + signaling fan-out.
//
// Concurrency: a single sync.RWMutex protects the user→device map.
// Sends to client channels are non-blocking — slow consumers drop events
// rather than stall the broadcaster.
type Hub struct {
mu sync.RWMutex
users map[string]map[string]*Client
devices DeviceLister
grace time.Duration
closed bool
}
func New(devices DeviceLister) *Hub {
return &Hub{
users: map[string]map[string]*Client{},
devices: devices,
grace: 30 * time.Second,
}
}
// Connect registers a new SSE client and announces presence to the user's other devices.
// If a client for (userID, deviceID) already exists (e.g., a tab refresh), its channel is closed.
func (h *Hub) Connect(ctx context.Context, userID, deviceID string) *Client {
c := &Client{
UserID: userID,
DeviceID: deviceID,
ch: make(chan Event, clientBuffer),
}
h.mu.Lock()
if h.users[userID] == nil {
h.users[userID] = map[string]*Client{}
}
if old, ok := h.users[userID][deviceID]; ok {
close(old.ch)
}
h.users[userID][deviceID] = c
h.mu.Unlock()
go h.publishPresence(ctx, userID)
return c
}
// Disconnect removes a client and, after a grace period, re-publishes presence
// to peers if the client has not reconnected.
func (h *Hub) Disconnect(c *Client) {
h.mu.Lock()
if active, ok := h.users[c.UserID][c.DeviceID]; ok && active == c {
delete(h.users[c.UserID], c.DeviceID)
if len(h.users[c.UserID]) == 0 {
delete(h.users, c.UserID)
}
}
h.mu.Unlock()
time.AfterFunc(h.grace, func() {
h.mu.RLock()
_, stillOnline := h.users[c.UserID][c.DeviceID]
h.mu.RUnlock()
if stillOnline {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
h.publishPresence(ctx, c.UserID)
})
}
// SendTo routes an event to a specific (userID, deviceID). Reports whether
// the target was online and the event was queued.
func (h *Hub) SendTo(userID, deviceID string, ev Event) bool {
h.mu.RLock()
c, ok := h.users[userID][deviceID]
h.mu.RUnlock()
if !ok {
return false
}
select {
case c.ch <- ev:
return true
default:
slog.Warn("client buffer full; dropping event",
"user", userID, "device", deviceID, "type", ev.Type)
return false
}
}
// Broadcast fans an event out to every live client of a user.
func (h *Hub) Broadcast(userID string, ev Event) {
clients := h.snapshotClients(userID)
for _, c := range clients {
select {
case c.ch <- ev:
default:
slog.Warn("client buffer full; dropping event",
"user", c.UserID, "device", c.DeviceID, "type", ev.Type)
}
}
}
// Online reports whether a (userID, deviceID) currently has a live SSE.
func (h *Hub) Online(userID, deviceID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
_, ok := h.users[userID][deviceID]
return ok
}
// Kick force-removes a (userID, deviceID) entry from the hub and closes its
// event channel; the SSE handler exits on the next iteration. 与 Connect 的
// 旧通道关闭模式一致(与并发 SendTo 之间存在极窄竞态,但 Kick 罕用,可接受)。
func (h *Hub) Kick(userID, deviceID string) {
h.mu.Lock()
c, ok := h.users[userID][deviceID]
if ok {
delete(h.users[userID], deviceID)
if len(h.users[userID]) == 0 {
delete(h.users, userID)
}
}
h.mu.Unlock()
if ok {
close(c.ch)
}
}
// PublishPresence broadcasts the user's current device list to all live
// clients. 用于在变更设备集合后(如 DELETE /api/devices/{name})立刻广播,
// 而不是等待 Disconnect 的宽限期。
func (h *Hub) PublishPresence(ctx context.Context, userID string) {
h.publishPresence(ctx, userID)
}
// Close drops every connected client. Used on graceful shutdown.
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
if h.closed {
return
}
h.closed = true
for _, devices := range h.users {
for _, c := range devices {
close(c.ch)
}
}
h.users = map[string]map[string]*Client{}
}
func (h *Hub) snapshotClients(userID string) []*Client {
h.mu.RLock()
defer h.mu.RUnlock()
src := h.users[userID]
out := make([]*Client, 0, len(src))
for _, c := range src {
out = append(out, c)
}
return out
}
func (h *Hub) publishPresence(ctx context.Context, userID string) {
devs, err := h.devices.ListDevicesByUser(ctx, userID)
if err != nil {
slog.Error("presence: list devices failed", "user", userID, "err", err)
return
}
h.mu.RLock()
live := h.users[userID]
items := make([]PresenceDevice, 0, len(devs))
for _, d := range devs {
_, online := live[d.Name]
items = append(items, PresenceDevice{
Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen,
})
}
clients := make([]*Client, 0, len(live))
for _, c := range live {
clients = append(clients, c)
}
h.mu.RUnlock()
ev := Event{
Type: "presence",
Data: map[string]any{"devices": items},
}
for _, c := range clients {
select {
case c.ch <- ev:
default:
slog.Warn("presence: client buffer full; dropping",
"user", c.UserID, "device", c.DeviceID)
}
}
}