Files
Commilitia-Drop/internal/clipboard/service.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

262 lines
8.3 KiB
Go

// Package clipboard 实现单条覆写式的剪贴板同步:DB 立即写入(GET 永远反映
// 最新真相),SSE 广播按 generation 计数节流——同窗口内的连续 PUT 只让最后
// 一次广播出去,避免下游设备被中间值抖动。
package clipboard
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
)
// originFutureToleranceMs caps how far ahead of server time a client's origin_ts
// may be. Beyond it we assume a runaway clock and clamp to server-now, so one
// mis-set device can't set an origin_ts no honest device could beat for a while.
const originFutureToleranceMs = 5 * 60 * 1000
const (
// ContentTypeText 是 MVP 阶段唯一允许写入的 content_type。前端 HTML→text
// fallback 在客户端完成,后端只接 text/plain。
ContentTypeText = "text/plain"
// EventType 是 SSE 推送给在线设备的事件名(brief §5 风格的 namespace:verb)。
EventType = "clipboard:update"
)
// Errors that the HTTP layer needs to distinguish.
var (
ErrUnsupportedType = errors.New("unsupported content_type")
ErrTooLarge = errors.New("content exceeds limit")
)
// Hub is the subset of *hub.Hub that the service needs. Declared as interface
// so unit tests can swap in a fake.
type Hub interface {
Broadcast(userID string, ev hub.Event)
}
// Querier 是 sqlc 生成的 *db.Queries 的子集,便于测试桩替换。
type Querier interface {
UpsertClipboard(ctx context.Context, arg db.UpsertClipboardParams) (int64, error)
GetClipboard(ctx context.Context, userID string) (db.ClipboardState, error)
ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error)
}
// Service 持有内存里的 pending 表,每个 userID 对应一个最新待广播的 generation。
type Service struct {
q Querier
hub Hub
max int
window time.Duration
ttl time.Duration // 0 = 内容不过期
mu sync.Mutex
pending map[string]uint64 // userID → 当前最新 generation
nextGen uint64
}
// New constructs a Service. window 是稳定窗口(PUT 收到后等待无新写入的时长,
// 到期才广播);maxBytes 是单次写入上限(UTF-8 字节);ttl 是云端内容存活时长
// (0 = 不过期)——见 Get 的惰性过期与 RunSweeper 的主动清除。
func New(q Querier, h Hub, maxBytes int, window, ttl time.Duration) *Service {
if maxBytes <= 0 {
maxBytes = 65536
}
if window <= 0 {
window = 3 * time.Second
}
return &Service{
q: q,
hub: h,
max: maxBytes,
window: window,
ttl: ttl,
pending: map[string]uint64{},
}
}
// MaxBytes 暴露上限给 HTTP 层做请求体校验。
func (s *Service) MaxBytes() int { return s.max }
// State 是 GET 返回 / SSE 广播 payload 的统一形状。Version 是变更探测的核心——
// 每用户严格单调递增;OriginTs 是冲突收敛的 LWW 键(源设备复制时刻 ms);客户端
// 据 Version 判变更、据 OriginTs 决定回写与否。
type State struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
SourceDevice string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
Version int64 `json:"version"`
OriginTs int64 `json:"origin_ts"`
}
// Get 读取当前持久化的剪贴板状态;err == sql.ErrNoRows 时表示用户尚未写入。
// 内容超过 TTL 即视为已过期,返回空状态(与「尚未写入」等价、ETag 0),绝不
// 回吐过期内容;实际的 DB 清除交给 RunSweeper。
func (s *Service) Get(ctx context.Context, userID string) (*State, error) {
row, err := s.q.GetClipboard(ctx, userID)
if err != nil {
return nil, err
}
st := rowToState(row)
if s.expired(st.Content, st.UpdatedAt) {
return &State{ContentType: ContentTypeText, UpdatedAt: 0}, nil
}
return st, nil
}
// expired 判断一条内容是否超过 TTL。ttl<=0 或内容为空时永不过期。
func (s *Service) expired(content string, updatedAt int64) bool {
if s.ttl <= 0 || content == "" {
return false
}
return time.Now().Unix()-updatedAt >= int64(s.ttl.Seconds())
}
// SweepExpired 清除所有超过 TTL 的剪贴板内容(content / source_device 置空),
// 返回清除的行数。ttl<=0 时为 no-op。
func (s *Service) SweepExpired(ctx context.Context) (int64, error) {
if s.ttl <= 0 {
return 0, nil
}
cutoff := time.Now().Unix() - int64(s.ttl.Seconds())
return s.q.ClearExpiredClipboards(ctx, cutoff)
}
// SweepInterval 是 sweeper 唤醒周期。
const SweepInterval = 30 * time.Second
// RunSweeper 阻塞直到 ctx 取消,周期性清除过期剪贴板内容。ttl<=0 时
// SweepExpired 为 no-op;调用方应据 cfg.ClipboardTTLSec>0 决定是否启动。
func RunSweeper(ctx context.Context, svc *Service) {
t := time.NewTicker(SweepInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
n, err := svc.SweepExpired(ctx)
if err != nil {
slog.Error("clipboard sweeper: clear expired failed", "err", err)
continue
}
if n > 0 {
slog.Info("clipboard sweeper: cleared expired content", "count", n)
}
}
}
}
// Put 接收一次写入:校验 → 立即 UPSERT → bump generation → 定时器到期才广播。
// 同 userID 在窗口期内的多次 Put 只让最后一次广播。
// Put writes a clipboard update tagged with originTs (the source device's copy
// time, ms). It is accepted only when originTs strictly exceeds the stored one
// (LWW); a delayed / out-of-order older copy is rejected with accepted=false and
// the current winning State returned (so the caller can reconcile). Only an
// accepted write bumps the version and schedules a broadcast.
func (s *Service) Put(
ctx context.Context,
userID, contentType, content, sourceDevice string,
originTs int64,
) (st *State, accepted bool, err error) {
if contentType != ContentTypeText {
return nil, false, fmt.Errorf("%w: %q", ErrUnsupportedType, contentType)
}
if len(content) > s.max {
return nil, false, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(content), s.max)
}
now := time.Now()
if nowMs := now.UnixMilli(); originTs > nowMs+originFutureToleranceMs {
originTs = nowMs // clamp a runaway-future clock to server-now
}
version, err := s.q.UpsertClipboard(ctx, db.UpsertClipboardParams{
UserID: userID,
ContentType: contentType,
Content: &content,
SourceDevice: &sourceDevice,
UpdatedAt: now.Unix(),
OriginTs: originTs,
})
if errors.Is(err, sql.ErrNoRows) {
// LWW rejected: a newer origin_ts already won. Return the current winner
// (no broadcast) so the caller pulls / reconciles instead of clobbering.
cur, gerr := s.Get(ctx, userID)
if gerr != nil {
return nil, false, gerr
}
return cur, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("upsert clipboard: %w", err)
}
st = &State{
Content: content,
ContentType: contentType,
SourceDevice: sourceDevice,
UpdatedAt: now.Unix(),
Version: version,
OriginTs: originTs,
}
s.mu.Lock()
s.nextGen++
gen := s.nextGen
s.pending[userID] = gen
s.mu.Unlock()
time.AfterFunc(s.window, func() { s.maybeCommit(userID, gen) })
return st, true, nil
}
// maybeCommit 由定时器回调进入。只有当 pending[userID] 仍然是入参 gen 时(即
// 期间没有更新写入)才真正广播;否则视为被新写入覆盖,直接丢弃。
func (s *Service) maybeCommit(userID string, gen uint64) {
s.mu.Lock()
cur, ok := s.pending[userID]
if !ok || cur != gen {
s.mu.Unlock()
return
}
delete(s.pending, userID)
s.mu.Unlock()
// 重新读 DB 拿权威 state——窗口结束时 DB 已是 gen 对应的内容(中间被
// 后续覆盖的可能性已被前面的 generation 检查排除)。
row, err := s.q.GetClipboard(context.Background(), userID)
if err != nil {
slog.Error("clipboard commit: get state failed", "err", err, "user", userID)
return
}
s.hub.Broadcast(userID, hub.Event{
Type: EventType,
Data: rowToState(row),
})
}
func rowToState(row db.ClipboardState) *State {
st := &State{
ContentType: row.ContentType,
UpdatedAt: row.UpdatedAt,
Version: row.Version,
OriginTs: row.OriginTs,
}
if row.Content != nil {
st.Content = *row.Content
}
if row.SourceDevice != nil {
st.SourceDevice = *row.SourceDevice
}
return st
}