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)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
// Package calls integrates Cloudflare Realtime TURN.
//
// Cloudflare Realtime issues short-lived ICE-server credentials via:
//
// POST https://rtc.live.cloudflare.com/v1/turn/keys/{KEY_ID}/credentials/generate-ice-servers
// Authorization: Bearer {API_TOKEN}
// {"ttl": 86400}
//
// Response 201:
//
// { "iceServers": [
// { "urls": ["stun:stun.cloudflare.com:3478", ...] },
// { "urls": ["turn:turn.cloudflare.com:3478?transport=udp",
// "turn:turn.cloudflare.com:3478?transport=tcp",
// "turns:turn.cloudflare.com:5349?transport=tcp"],
// "username": "...", "credential": "..." }
// ]}
//
// Free of charge for cdrop's egress volume (1 TB/mo soft cap is enough).
package calls
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
const (
endpointTmpl = "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers"
// DefaultTTL caps how long an issued TURN credential stays valid (R3). The
// same bundle is shared by every client of this instance, so a user who
// extracts it could relay traffic on cdrop's Cloudflare quota until it
// expires; a short TTL keeps that window small. 1h is ample to set up a
// transfer (creds authenticate the Allocate; the allocation outlives them).
DefaultTTL = 1 * time.Hour
requestTimeout = 10 * time.Second
)
type ICEServer struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
type ICEResponse struct {
ICEServers []ICEServer `json:"iceServers"`
}
// Provider is concurrency-safe and caches generated credentials so multiple
// concurrent web clients share one upstream call to Cloudflare.
type Provider struct {
keyID string
apiToken string
ttl time.Duration
refreshMargin time.Duration
client *http.Client
mu sync.Mutex
cached *ICEResponse
expiresAt time.Time
}
// NewProvider returns a Provider configured to mint creds with the given TTL.
// ttl ≤ 0 falls back to DefaultTTL. The cache refreshes once a served credential
// drops below refreshMargin (a quarter of the TTL, floored at 5m) of remaining
// life, so every credential handed out still has a comfortable validity window
// even with a short TTL.
func NewProvider(keyID, apiToken string, ttl time.Duration) *Provider {
if ttl <= 0 {
ttl = DefaultTTL
}
margin := ttl / 4
if margin < 5*time.Minute {
margin = 5 * time.Minute
}
if margin >= ttl {
// Pathologically short TTLs: keep margin under the TTL so the cache can
// still serve at least briefly rather than refetching on every call.
margin = ttl / 2
}
return &Provider{
keyID: keyID,
apiToken: apiToken,
ttl: ttl,
refreshMargin: margin,
client: &http.Client{Timeout: requestTimeout},
}
}
// Get returns a fresh ICE-server bundle. Repeated calls within the TTL window
// (minus refreshMargin) return the cached value; otherwise a new fetch happens.
// On upstream failure, falls back to the most recent cached value if any.
func (p *Provider) Get(ctx context.Context) (*ICEResponse, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.cached != nil && time.Until(p.expiresAt) > p.refreshMargin {
return p.cached, nil
}
creds, err := p.fetch(ctx)
if err != nil {
if p.cached != nil {
slog.Warn("cf turn refresh failed; using cached creds",
"err", err, "expires_in", time.Until(p.expiresAt))
return p.cached, nil
}
return nil, err
}
p.cached = creds
p.expiresAt = time.Now().Add(p.ttl)
slog.Info("cf turn creds refreshed",
"servers", len(creds.ICEServers), "ttl", p.ttl)
return creds, nil
}
func (p *Provider) fetch(ctx context.Context) (*ICEResponse, error) {
body := bytes.NewReader([]byte(fmt.Sprintf(`{"ttl":%d}`, int(p.ttl.Seconds()))))
url := fmt.Sprintf(endpointTmpl, p.keyID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+p.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("cloudflare unreachable: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("cloudflare turn status %d: %s",
resp.StatusCode, truncate(string(raw), 200))
}
var r ICEResponse
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(r.ICEServers) == 0 {
return nil, errors.New("cloudflare returned empty iceServers")
}
return &r, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+33
View File
@@ -0,0 +1,33 @@
package calls
import (
"testing"
"time"
)
// The refresh margin scales with the TTL (a quarter, floored at 5m) so a short
// R3 TTL still leaves every served credential a comfortable validity window
// without refetching on every call.
func TestNewProvider_RefreshMargin(t *testing.T) {
cases := []struct {
name string
ttl time.Duration
want time.Duration
}{
{"default when zero", 0, DefaultTTL / 4}, // ttl<=0 → DefaultTTL (1h) → 15m
{"one hour", time.Hour, 15 * time.Minute}, // quarter
{"quarter floored at 5m", 10 * time.Minute, 5 * time.Minute}, // 2.5m → floor 5m, 5m<10m
{"pathologically short", 4 * time.Minute, 2 * time.Minute}, // floor 5m >= 4m ttl → ttl/2
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := NewProvider("k", "tok", c.ttl)
if p.refreshMargin != c.want {
t.Errorf("refreshMargin: got %v, want %v", p.refreshMargin, c.want)
}
if p.refreshMargin >= p.ttl {
t.Errorf("margin %v must stay under ttl %v so the cache can serve", p.refreshMargin, p.ttl)
}
})
}
}
+261
View File
@@ -0,0 +1,261 @@
// 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
}
+378
View File
@@ -0,0 +1,378 @@
package clipboard
import (
"context"
"database/sql"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
)
// fakeQuerier 在内存里模拟 clipboard_state 表,按 user_id 单条覆写。
type fakeQuerier struct {
mu sync.Mutex
rows map[string]db.ClipboardState
gets atomic.Int32
puts atomic.Int32
}
func newFakeQuerier() *fakeQuerier {
return &fakeQuerier{rows: map[string]db.ClipboardState{}}
}
func (f *fakeQuerier) UpsertClipboard(_ context.Context, arg db.UpsertClipboardParams) (int64, error) {
f.puts.Add(1)
f.mu.Lock()
defer f.mu.Unlock()
prev, exists := f.rows[arg.UserID]
if exists && arg.OriginTs <= prev.OriginTs {
return 0, sql.ErrNoRows // conditional WHERE failed → stale, no change (RETURNING no row)
}
version := prev.Version + 1 // absent → 0 → new version 1; else prev+1
f.rows[arg.UserID] = db.ClipboardState{
UserID: arg.UserID,
ContentType: arg.ContentType,
Content: arg.Content,
SourceDevice: arg.SourceDevice,
UpdatedAt: arg.UpdatedAt,
Version: version,
OriginTs: arg.OriginTs,
}
return version, nil
}
func (f *fakeQuerier) GetClipboard(_ context.Context, userID string) (db.ClipboardState, error) {
f.gets.Add(1)
f.mu.Lock()
defer f.mu.Unlock()
row, ok := f.rows[userID]
if !ok {
return db.ClipboardState{}, errNoRow
}
return row, nil
}
// ClearExpiredClipboards 模拟 SQL:把 content 非空且 updated_at < cutoff 的行
// 内容置 nil,返回清除行数。
func (f *fakeQuerier) ClearExpiredClipboards(_ context.Context, cutoff int64) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
var n int64
for k, row := range f.rows {
if row.Content != nil && row.UpdatedAt < cutoff {
row.Content = nil
row.SourceDevice = nil
f.rows[k] = row
n++
}
}
return n, nil
}
var errNoRow = errors.New("no row")
// fakeHub 计数 Broadcast 调用并把 events 排队让测试断言。
type fakeHub struct {
mu sync.Mutex
events []hub.Event
}
func (h *fakeHub) Broadcast(_ string, ev hub.Event) {
h.mu.Lock()
h.events = append(h.events, ev)
h.mu.Unlock()
}
func (h *fakeHub) snapshot() []hub.Event {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]hub.Event, len(h.events))
copy(out, h.events)
return out
}
func TestPutWritesDBImmediately(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 50*time.Millisecond, 0)
if _, _, err := s.Put(context.Background(), "alice", "text/plain", "hello", "tab-1", 1); err != nil {
t.Fatalf("Put: %v", err)
}
if got := q.puts.Load(); got != 1 {
t.Errorf("expected 1 DB upsert, got %d", got)
}
got, err := s.Get(context.Background(), "alice")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Content != "hello" {
t.Errorf("content: got %q, want %q", got.Content, "hello")
}
}
func TestPutTrailingPutOnlyBroadcastsLast(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 60*time.Millisecond, 0)
ctx := context.Background()
for i, text := range []string{"a", "b", "c"} {
if _, _, err := s.Put(ctx, "alice", "text/plain", text, "tab-1", int64(i+1)); err != nil {
t.Fatalf("Put %q: %v", text, err)
}
time.Sleep(10 * time.Millisecond) // 每次写都在同窗口内
}
// 等待最后一次 Put 之后的窗口结束 + 余量
time.Sleep(150 * time.Millisecond)
evs := h.snapshot()
if len(evs) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(evs))
}
st, ok := evs[0].Data.(*State)
if !ok {
t.Fatalf("event data type: %T", evs[0].Data)
}
if st.Content != "c" {
t.Errorf("broadcast content: got %q, want %q", st.Content, "c")
}
if evs[0].Type != EventType {
t.Errorf("event type: got %q, want %q", evs[0].Type, EventType)
}
}
func TestPutDifferentUsersDontInterfere(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 40*time.Millisecond, 0)
ctx := context.Background()
if _, _, err := s.Put(ctx, "alice", "text/plain", "alice-data", "tab-1", 1); err != nil {
t.Fatalf("alice: %v", err)
}
if _, _, err := s.Put(ctx, "bob", "text/plain", "bob-data", "tab-1", 1); err != nil {
t.Fatalf("bob: %v", err)
}
time.Sleep(120 * time.Millisecond)
evs := h.snapshot()
if len(evs) != 2 {
t.Fatalf("expected 2 broadcasts (one per user), got %d", len(evs))
}
}
func TestPutRejectsUnsupportedType(t *testing.T) {
s := New(newFakeQuerier(), &fakeHub{}, 1024, 10*time.Millisecond, 0)
_, _, err := s.Put(context.Background(), "alice", "text/html", "<b>hi</b>", "tab-1", 1)
if !errors.Is(err, ErrUnsupportedType) {
t.Errorf("expected ErrUnsupportedType, got %v", err)
}
}
func TestPutRejectsTooLarge(t *testing.T) {
s := New(newFakeQuerier(), &fakeHub{}, 8, 10*time.Millisecond, 0)
_, _, err := s.Put(context.Background(), "alice", "text/plain", "123456789", "tab-1", 1)
if !errors.Is(err, ErrTooLarge) {
t.Errorf("expected ErrTooLarge, got %v", err)
}
}
func TestPutEmptyContentAccepted(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 30*time.Millisecond, 0)
if _, _, err := s.Put(context.Background(), "alice", "text/plain", "", "tab-1", 1); err != nil {
t.Errorf("empty content should be accepted (clear semantics): %v", err)
}
time.Sleep(80 * time.Millisecond)
evs := h.snapshot()
if len(evs) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(evs))
}
st := evs[0].Data.(*State)
if st.Content != "" {
t.Errorf("expected empty content, got %q", st.Content)
}
}
func TestPutWindowExtendsOnNewWrite(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
window := 80 * time.Millisecond
s := New(q, h, 1024, window, 0)
ctx := context.Background()
t0 := time.Now()
_, _, _ = s.Put(ctx, "alice", "text/plain", "first", "tab-1", 1)
time.Sleep(window / 2)
_, _, _ = s.Put(ctx, "alice", "text/plain", "second", "tab-1", 2)
// 第一窗到期点:任何广播都不该出现(被第二个 Put 重置)
time.Sleep(window/2 + 10*time.Millisecond)
if got := len(h.snapshot()); got != 0 {
t.Errorf("at t=%v, expected 0 broadcasts (window not yet elapsed for second), got %d",
time.Since(t0), got)
}
// 第二窗到期之后 broadcast 应该出现,且内容是 "second"
time.Sleep(window/2 + 30*time.Millisecond)
evs := h.snapshot()
if len(evs) != 1 {
t.Fatalf("expected 1 broadcast after second window, got %d", len(evs))
}
if got := evs[0].Data.(*State).Content; got != "second" {
t.Errorf("broadcast content: got %q, want %q", got, "second")
}
}
// --- TTL(短存活)---
func seedRow(q *fakeQuerier, userID, content string, ageSec int64) {
c := content
src := "tab-1"
q.rows[userID] = db.ClipboardState{
UserID: userID,
ContentType: "text/plain",
Content: &c,
SourceDevice: &src,
UpdatedAt: time.Now().Unix() - ageSec,
}
}
func TestGetExpiredReturnsEmpty(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "secret", 100) // 100s 前写入
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
got, err := s.Get(context.Background(), "alice")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Content != "" {
t.Errorf("过期内容不应回吐,got %q", got.Content)
}
if got.UpdatedAt != 0 {
t.Errorf("过期状态 UpdatedAt 应为 0(等同未写入),got %d", got.UpdatedAt)
}
}
func TestGetFreshWithinTTL(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "recent", 5) // 5s 前
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
got, err := s.Get(context.Background(), "alice")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Content != "recent" {
t.Errorf("TTL 内的新内容应返回,got %q", got.Content)
}
}
func TestGetTTLDisabledNeverExpires(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "old", 100000)
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0) // ttl=0 → 禁用
got, _ := s.Get(context.Background(), "alice")
if got.Content != "old" {
t.Errorf("ttl=0 永不过期,got %q", got.Content)
}
}
func TestSweepExpiredClearsOldOnly(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "old-secret", 100) // 过期
seedRow(q, "bob", "recent", 5) // 未过期
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
n, err := s.SweepExpired(context.Background())
if err != nil {
t.Fatalf("SweepExpired: %v", err)
}
if n != 1 {
t.Errorf("应清除 1 行(alice),got %d", n)
}
if c := q.rows["alice"].Content; c != nil {
t.Errorf("alice 的内容应被清空(nil),got %v", *c)
}
if c := q.rows["bob"].Content; c == nil || *c != "recent" {
t.Errorf("bob 的新内容应存活,got %v", c)
}
}
func TestSweepExpiredNoopWhenDisabled(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "x", 100000)
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0)
n, err := s.SweepExpired(context.Background())
if err != nil || n != 0 {
t.Errorf("ttl=0 时 SweepExpired 应为 noopgot n=%d err=%v", n, err)
}
if q.rows["alice"].Content == nil {
t.Error("ttl=0 不应清除任何内容")
}
}
func TestPutBumpsVersionMonotonically(t *testing.T) {
svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0)
st1, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "a", "devA", 1)
if err != nil || !ok {
t.Fatalf("put 1: ok=%v err=%v", ok, err)
}
if st1.Version != 1 {
t.Errorf("first put version: got %d, want 1", st1.Version)
}
st2, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "b", "devA", 2)
if err != nil || !ok {
t.Fatalf("put 2: ok=%v err=%v", ok, err)
}
if st2.Version != 2 {
t.Errorf("second put version: got %d, want 2", st2.Version)
}
got, err := svc.Get(context.Background(), "u")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != 2 || got.SourceDevice != "devA" {
t.Errorf("get: version=%d source=%q, want 2/devA", got.Version, got.SourceDevice)
}
}
// LWW: a delayed/out-of-order write with an older-or-equal origin_ts must be
// rejected (accepted=false) and leave the current winner intact.
func TestPutRejectsStaleOrigin(t *testing.T) {
svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0)
ctx := context.Background()
st, ok, err := svc.Put(ctx, "u", ContentTypeText, "new", "devA", 200)
if err != nil || !ok || st.Version != 1 {
t.Fatalf("first put: ok=%v ver=%v err=%v", ok, st.Version, err)
}
// Older copy arriving late → rejected, current winner returned.
cur, ok, err := svc.Put(ctx, "u", ContentTypeText, "stale", "devB", 100)
if err != nil {
t.Fatalf("stale put err: %v", err)
}
if ok {
t.Error("older origin_ts must be rejected")
}
if cur.Content != "new" || cur.Version != 1 {
t.Errorf("rejected put must return current winner; got content=%q ver=%d", cur.Content, cur.Version)
}
// Equal origin_ts is not strictly greater → rejected (idempotent duplicate).
if _, ok, _ := svc.Put(ctx, "u", ContentTypeText, "dup", "devA", 200); ok {
t.Error("equal origin_ts must be rejected (idempotent)")
}
}
+134
View File
@@ -0,0 +1,134 @@
package config
import (
"errors"
"fmt"
"os"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
const envPrefix = "CDROP_"
type Config struct {
AuthMode string `koanf:"auth_mode"`
DevToken string `koanf:"dev_token"`
DBPath string `koanf:"db_path"`
Listen string `koanf:"listen"`
// DeviceTTLHours is the maximum age of a row in `devices` before the
// background sweeper deletes it. Brief §2 sets the refresh_token sliding
// window at 196h (≈8 days) — devices must not outlive that. Default 196h.
DeviceTTLHours int `koanf:"device_ttl_hours"`
// OIDC settings. Generic OAuth/OIDC provider compatible (Casdoor included).
OIDCAuthorizeURL string `koanf:"oidc_authorize_url"`
OIDCTokenURL string `koanf:"oidc_token_url"`
OIDCJWKSURL string `koanf:"oidc_jwks_url"`
OIDCIssuer string `koanf:"oidc_issuer"`
// OIDCAudience accepts a comma-separated list. A token passes when its `aud`
// matches any one entry — this lets one backend serve several OAuth clients
// that carry different audiences (e.g. the web app and the desktop client).
// Empty disables audience checking; a single value behaves as before.
OIDCAudience string `koanf:"oidc_audience"`
OIDCClientID string `koanf:"oidc_client_id"`
OIDCClientSecret string `koanf:"oidc_client_secret"`
OIDCRedirectURI string `koanf:"oidc_redirect_uri"`
OIDCScopes string `koanf:"oidc_scopes"`
HS256Secret string `koanf:"hs256_secret"`
// Shortcut tokensiOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期
// (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。
// 需配 HS256Secret 才启用,否则签发端点返回 503。
ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"`
ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"`
// Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/).
// When both fields are set, /api/calls/credentials returns short-lived
// TLS-capable TURN creds (turns:turn.cloudflare.com:5349); otherwise the
// endpoint serves a STUN-only fallback so dev / no-CF deploys still work.
CFTurnKeyID string `koanf:"cf_turn_key_id"`
CFTurnAPIToken string `koanf:"cf_turn_api_token"`
// Clipboard:单条覆写式同步。MaxBytes 限制单次写入字节数;DebounceSec 是
// 收到 PUT 后等待"无新写入稳定窗口"的秒数,到期才广播 SSE。同窗口内的
// 后续 PUT 直接刷新 generation 让旧广播取消。
ClipboardMaxBytes int `koanf:"clipboard_max_bytes"`
ClipboardDebounceSec int `koanf:"clipboard_debounce_sec"`
// ClipboardTTLSec 是云剪贴板内容的存活秒数。因为无法可靠区分密码与普通
// 文本(密码.app / 浏览器扩展复制都不打敏感标记),改以短 TTL 限制暴露面:
// 内容超过 TTL 后 GET 返回空,后台 sweeper 亦清除其 content。0 = 不过期。
ClipboardTTLSec int `koanf:"clipboard_ttl_sec"`
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
// Validates dev/prod invariants; returns error if invariants are violated.
func Load() (*Config, error) {
k := koanf.New(".")
k.Set("auth_mode", "prod")
k.Set("db_path", "./cdrop.db")
k.Set("listen", ":8080")
k.Set("device_ttl_hours", 196)
k.Set("oidc_scopes", "openid profile email")
k.Set("clipboard_max_bytes", 65536)
k.Set("clipboard_debounce_sec", 3)
k.Set("shortcut_token_ttl_days", 365)
k.Set("shortcut_max_per_user", 10)
if _, err := os.Stat("config.yaml"); err == nil {
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
return nil, fmt.Errorf("load config.yaml: %w", err)
}
}
envProvider := env.Provider(".", env.Opt{
Prefix: envPrefix,
TransformFunc: func(key, value string) (string, any) {
return strings.ToLower(strings.TrimPrefix(key, envPrefix)), value
},
})
if err := k.Load(envProvider, nil); err != nil {
return nil, fmt.Errorf("load env: %w", err)
}
cfg := &Config{}
if err := k.Unmarshal("", cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) validate() error {
switch c.AuthMode {
case "dev":
if c.DevToken == "" {
return errors.New("CDROP_AUTH_MODE=dev requires CDROP_DEV_TOKEN; refusing to start")
}
case "prod":
// Audience MUST be set in prod (R6). With it empty, RS256 audience
// checking is skipped entirely, so ANY token the IdP mints for ANY
// application sharing this JWKS would validate here — a user of a
// sibling Casdoor app could impersonate a cdrop user. The web + desktop
// client_ids go in CDROP_OIDC_AUDIENCE (comma-separated); refuse to boot
// without it rather than run wide open.
if c.OIDCAudience == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " +
"(comma-separated OAuth client_ids); refusing to start")
}
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"strings"
"testing"
)
func TestValidate_DevRequiresDevToken(t *testing.T) {
c := &Config{AuthMode: "dev", DevToken: ""}
err := c.validate()
if err == nil {
t.Fatal("dev mode without DevToken must reject; got nil error")
}
if !strings.Contains(err.Error(), "CDROP_DEV_TOKEN") {
t.Errorf("error must mention CDROP_DEV_TOKEN; got %q", err.Error())
}
}
func TestValidate_DevWithDevTokenOK(t *testing.T) {
c := &Config{AuthMode: "dev", DevToken: "x"}
if err := c.validate(); err != nil {
t.Fatalf("dev with token should pass; got %v", err)
}
}
func TestValidate_ProdOK(t *testing.T) {
c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop"}
if err := c.validate(); err != nil {
t.Fatalf("prod mode with audience should pass; got %v", err)
}
}
func TestValidate_ProdRequiresAudience(t *testing.T) {
// R6: prod with an empty audience leaves RS256 audience checking off, so a
// token minted for any sibling app sharing the JWKS would validate. Refuse.
c := &Config{AuthMode: "prod", OIDCAudience: ""}
err := c.validate()
if err == nil {
t.Fatal("prod without CDROP_OIDC_AUDIENCE must reject; got nil error")
}
if !strings.Contains(err.Error(), "CDROP_OIDC_AUDIENCE") {
t.Errorf("error must mention CDROP_OIDC_AUDIENCE; got %q", err.Error())
}
}
func TestValidate_UnknownMode(t *testing.T) {
c := &Config{AuthMode: "rogue"}
err := c.validate()
if err == nil {
t.Fatal("unknown auth mode must reject")
}
}
func TestLoad_EnvOverridesDefaults(t *testing.T) {
t.Setenv("CDROP_AUTH_MODE", "dev")
t.Setenv("CDROP_DEV_TOKEN", "abc")
t.Setenv("CDROP_LISTEN", ":9090")
t.Setenv("CDROP_DB_PATH", "/tmp/x.db")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.AuthMode != "dev" {
t.Errorf("AuthMode: got %q, want %q", cfg.AuthMode, "dev")
}
if cfg.DevToken != "abc" {
t.Errorf("DevToken: got %q, want %q", cfg.DevToken, "abc")
}
if cfg.Listen != ":9090" {
t.Errorf("Listen: got %q, want %q", cfg.Listen, ":9090")
}
if cfg.DBPath != "/tmp/x.db" {
t.Errorf("DBPath: got %q, want %q", cfg.DBPath, "/tmp/x.db")
}
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"context"
"database/sql"
_ "embed"
"fmt"
"net/url"
_ "modernc.org/sqlite"
)
//go:embed migrations/0001_init.sql
var initSchema string
// Open dials a sqlite db with WAL + busy timeout pragmas wired in.
// dbPath is the on-disk file path; passing ":memory:" works for tests.
func Open(dbPath string) (*sql.DB, error) {
dsn := buildDSN(dbPath)
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := d.PingContext(context.Background()); err != nil {
_ = d.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return d, nil
}
func buildDSN(dbPath string) string {
q := url.Values{}
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "foreign_keys(1)")
return dbPath + "?" + q.Encode()
}
// Bootstrap runs the embedded init schema in a single transaction.
// Idempotent: every CREATE TABLE uses IF NOT EXISTS.
func Bootstrap(ctx context.Context, d *sql.DB) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin bootstrap tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, initSchema); err != nil {
return fmt.Errorf("exec init schema: %w", err)
}
// Additive migration for DBs created before clipboard_state.version existed:
// CREATE TABLE IF NOT EXISTS won't add the column to an existing table, and
// SQLite has no ADD COLUMN IF NOT EXISTS, so guard it with a pragma check.
if err := ensureColumn(ctx, tx, "clipboard_state", "version",
"ALTER TABLE clipboard_state ADD COLUMN version INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate clipboard_state.version: %w", err)
}
if err := ensureColumn(ctx, tx, "clipboard_state", "origin_ts",
"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit bootstrap: %w", err)
}
return nil
}
// ensureColumn runs addSQL only when table lacks the named column — an idempotent
// additive migration for already-created tables.
func ensureColumn(ctx context.Context, tx *sql.Tx, table, column, addSQL string) error {
has, err := columnExists(ctx, tx, table, column)
if err != nil {
return err
}
if has {
return nil
}
_, err = tx.ExecContext(ctx, addSQL)
return err
}
func columnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
// table is a trusted compile-time constant, not user input.
rows, err := tx.QueryContext(ctx, "PRAGMA table_info("+table+")")
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var (
cid, notnull, pk int
name, ctype string
dflt sql.NullString
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
+116
View File
@@ -0,0 +1,116 @@
package db
import (
"context"
"path/filepath"
"sort"
"testing"
)
func TestBootstrapCreatesAllTables(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "test.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("bootstrap: %v", err)
}
want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_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)
}
defer rows.Close()
var got []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, name)
}
sort.Strings(got)
if len(got) != len(want) {
t.Fatalf("table count: got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("table[%d]: got %q, want %q", i, got[i], want[i])
}
}
}
func TestBootstrapIsIdempotent(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "test.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
for i := 0; i < 3; i++ {
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("bootstrap iter %d: %v", i, err)
}
}
}
func TestOpenEnablesWALMode(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "test.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
var mode string
if err := d.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("pragma: %v", err)
}
if mode != "wal" {
t.Errorf("journal_mode: got %q, want %q", mode, "wal")
}
}
// Simulates the prod upgrade: a clipboard_state created before the version
// column existed must gain it (defaulting to 0) on bootstrap, idempotently.
func TestBootstrapAddsClipboardVersionToLegacyDB(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "legacy.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
_, err = d.Exec(`CREATE TABLE clipboard_state (
user_id TEXT PRIMARY KEY, content_type TEXT NOT NULL, content TEXT,
source_device TEXT, updated_at INTEGER NOT NULL)`)
if err != nil {
t.Fatalf("legacy schema: %v", err)
}
if _, err := d.Exec(`INSERT INTO clipboard_state (user_id, content_type, content, updated_at)
VALUES ('u', 'text/plain', 'hi', 100)`); err != nil {
t.Fatalf("seed row: %v", err)
}
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("bootstrap: %v", err)
}
var version int64
if err := d.QueryRow(`SELECT version FROM clipboard_state WHERE user_id='u'`).Scan(&version); err != nil {
t.Fatalf("select version (column missing?): %v", err)
}
if version != 0 {
t.Errorf("legacy row version: got %d, want 0", version)
}
// Second bootstrap must be a no-op (column already present).
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
}
+87
View File
@@ -0,0 +1,87 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: clipboard.sql
package db
import (
"context"
)
const clearExpiredClipboards = `-- name: ClearExpiredClipboards :execrows
UPDATE clipboard_state
SET content =
`
// 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
// 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
func (q *Queries) ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error) {
result, err := q.db.ExecContext(ctx, clearExpiredClipboards, updatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getClipboard = `-- name: GetClipboard :one
SELECT user_id, content_type, content, source_device, updated_at, version, origin_ts
FROM clipboard_state
WHERE user_id = ?
`
func (q *Queries) GetClipboard(ctx context.Context, userID string) (ClipboardState, error) {
row := q.db.QueryRowContext(ctx, getClipboard, userID)
var i ClipboardState
err := row.Scan(
&i.UserID,
&i.ContentType,
&i.Content,
&i.SourceDevice,
&i.UpdatedAt,
&i.Version,
&i.OriginTs,
)
return i, err
}
const upsertClipboard = `-- name: UpsertClipboard :one
INSERT INTO clipboard_state (user_id, content_type, content, source_device, updated_at, version, origin_ts)
VALUES (?, ?, ?, ?, ?, 1, ?)
ON CONFLICT (user_id) DO UPDATE SET
content_type = excluded.content_type,
content = excluded.content,
source_device = excluded.source_device,
updated_at = excluded.updated_at,
version = clipboard_state.version + 1,
origin_ts = excluded.origin_ts
WHERE excluded.origin_ts > clipboard_state.origin_ts
RETURNING version
`
type UpsertClipboardParams struct {
UserID string `json:"user_id"`
ContentType string `json:"content_type"`
Content *string `json:"content"`
SourceDevice *string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
OriginTs int64 `json:"origin_ts"`
}
// New rows start version 1; conflicts bump version by 1 (per-user monotonic).
// LWW: the DO UPDATE only runs when the new origin_ts is strictly greater than
// the stored one, so a delayed / out-of-order older copy changes nothing and
// RETURNING yields no row (the service reads sql.ErrNoRows as "stale, rejected").
func (q *Queries) UpsertClipboard(ctx context.Context, arg UpsertClipboardParams) (int64, error) {
row := q.db.QueryRowContext(ctx, upsertClipboard,
arg.UserID,
arg.ContentType,
arg.Content,
arg.SourceDevice,
arg.UpdatedAt,
arg.OriginTs,
)
var version int64
err := row.Scan(&version)
return version, err
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
+98
View File
@@ -0,0 +1,98 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: devices.sql
package db
import (
"context"
)
const deleteDevice = `-- name: DeleteDevice :execrows
DELETE FROM devices
WHERE user_id = ? AND name = ?
`
type DeleteDeviceParams struct {
UserID string `json:"user_id"`
Name string `json:"name"`
}
func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteDevice, arg.UserID, arg.Name)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
DELETE FROM devices
WHERE last_seen < ?
`
func (q *Queries) DeleteStaleDevices(ctx context.Context, lastSeen int64) error {
_, err := q.db.ExecContext(ctx, deleteStaleDevices, lastSeen)
return err
}
const listDevicesByUser = `-- name: ListDevicesByUser :many
SELECT user_id, name, type, last_seen
FROM devices
WHERE user_id = ?
ORDER BY name
`
func (q *Queries) ListDevicesByUser(ctx context.Context, userID string) ([]Device, error) {
rows, err := q.db.QueryContext(ctx, listDevicesByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Device
for rows.Next() {
var i Device
if err := rows.Scan(
&i.UserID,
&i.Name,
&i.Type,
&i.LastSeen,
); 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 upsertDevice = `-- name: UpsertDevice :exec
INSERT INTO devices (user_id, name, type, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, name) DO UPDATE SET
type = excluded.type,
last_seen = excluded.last_seen
`
type UpsertDeviceParams struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
LastSeen int64 `json:"last_seen"`
}
func (q *Queries) UpsertDevice(ctx context.Context, arg UpsertDeviceParams) error {
_, err := q.db.ExecContext(ctx, upsertDevice,
arg.UserID,
arg.Name,
arg.Type,
arg.LastSeen,
)
return err
}
+51
View File
@@ -0,0 +1,51 @@
-- cdrop MVP schema (PROJECT_BRIEF.md §4)
-- 以 IF NOT EXISTS 形式由 internal/db/bootstrap.go 启动期单事务执行。
CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY (user_id, name)
);
CREATE TABLE IF NOT EXISTS shortcut_tokens (
jti TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
label TEXT NOT NULL,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS transfer_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
sender_name TEXT NOT NULL,
receiver_name TEXT NOT NULL,
state TEXT NOT NULL,
mode TEXT,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_sha256 TEXT,
bytes_transferred INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
finished_at INTEGER,
fail_reason TEXT
);
CREATE TABLE IF NOT EXISTS clipboard_state (
user_id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
content TEXT,
source_device TEXT,
updated_at INTEGER NOT NULL,
-- version 是每用户严格单调递增的版本号(每次写 +1),变更探测 / ETag 用;
-- updated_at 仍是墙钟(TTL / 展示)。既有库由 bootstrap 的幂等 ALTER 补列。
version INTEGER NOT NULL DEFAULT 0,
-- origin_ts 是内容在源设备被复制的时刻(ms)。冲突收敛按它做 LWW——写入仅在
-- origin_ts 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的。
origin_ts INTEGER NOT NULL DEFAULT 0
);
+49
View File
@@ -0,0 +1,49 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
type ClipboardState struct {
UserID string `json:"user_id"`
ContentType string `json:"content_type"`
Content *string `json:"content"`
SourceDevice *string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
Version int64 `json:"version"`
OriginTs int64 `json:"origin_ts"`
}
type Device struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
LastSeen int64 `json:"last_seen"`
}
type ShortcutToken struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
Label string `json:"label"`
Scopes string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt *int64 `json:"last_used_at"`
Revoked int64 `json:"revoked"`
}
type TransferSession struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SenderName string `json:"sender_name"`
ReceiverName string `json:"receiver_name"`
State string `json:"state"`
Mode *string `json:"mode"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
FileSha256 *string `json:"file_sha256"`
BytesTransferred int64 `json:"bytes_transferred"`
CreatedAt int64 `json:"created_at"`
FinishedAt *int64 `json:"finished_at"`
FailReason *string `json:"fail_reason"`
}
+28
View File
@@ -0,0 +1,28 @@
-- name: UpsertClipboard :one
-- New rows start version 1; conflicts bump version by 1 (per-user monotonic).
-- LWW: the DO UPDATE only runs when the new origin_ts is strictly greater than
-- the stored one, so a delayed / out-of-order older copy changes nothing and
-- RETURNING yields no row (the service reads sql.ErrNoRows as "stale, rejected").
INSERT INTO clipboard_state (user_id, content_type, content, source_device, updated_at, version, origin_ts)
VALUES (?, ?, ?, ?, ?, 1, ?)
ON CONFLICT (user_id) DO UPDATE SET
content_type = excluded.content_type,
content = excluded.content,
source_device = excluded.source_device,
updated_at = excluded.updated_at,
version = clipboard_state.version + 1,
origin_ts = excluded.origin_ts
WHERE excluded.origin_ts > clipboard_state.origin_ts
RETURNING version;
-- name: GetClipboard :one
SELECT user_id, content_type, content, source_device, updated_at, version, origin_ts
FROM clipboard_state
WHERE user_id = ?;
-- name: ClearExpiredClipboards :execrows
-- 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
-- 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
UPDATE clipboard_state
SET content = NULL, source_device = NULL
WHERE content IS NOT NULL AND updated_at < ?;
+20
View File
@@ -0,0 +1,20 @@
-- name: UpsertDevice :exec
INSERT INTO devices (user_id, name, type, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, name) DO UPDATE SET
type = excluded.type,
last_seen = excluded.last_seen;
-- name: ListDevicesByUser :many
SELECT user_id, name, type, last_seen
FROM devices
WHERE user_id = ?
ORDER BY name;
-- name: DeleteStaleDevices :exec
DELETE FROM devices
WHERE last_seen < ?;
-- name: DeleteDevice :execrows
DELETE FROM devices
WHERE user_id = ? AND name = ?;
+32
View File
@@ -0,0 +1,32 @@
-- shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
-- iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
-- user_id to block cross-user revocation; Touch records last use asynchronously.
-- name: InsertShortcutToken :exec
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?);
-- name: GetShortcutToken :one
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE jti = ?;
-- name: ListShortcutTokensByUser :many
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE user_id = ?
ORDER BY created_at DESC;
-- name: CountActiveShortcutTokensByUser :one
SELECT COUNT(*) FROM shortcut_tokens
WHERE user_id = ? AND revoked = 0 AND expires_at > ?;
-- name: RevokeShortcutToken :execrows
UPDATE shortcut_tokens
SET revoked = 1
WHERE jti = ? AND user_id = ?;
-- name: TouchShortcutTokenUsed :exec
UPDATE shortcut_tokens
SET last_used_at = ?
WHERE jti = ?;
+30
View File
@@ -0,0 +1,30 @@
-- name: CreateTransferSession :exec
INSERT INTO transfer_sessions (
id, user_id, sender_name, receiver_name, state,
file_name, file_size, file_sha256, created_at
)
VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?, ?);
-- name: GetTransferSession :one
SELECT id, user_id, sender_name, receiver_name, state, mode,
file_name, file_size, file_sha256, bytes_transferred,
created_at, finished_at, fail_reason
FROM transfer_sessions
WHERE id = ?;
-- name: UpdateTransferState :execrows
UPDATE transfer_sessions
SET state = sqlc.arg('state'),
mode = COALESCE(sqlc.narg('mode'), mode),
fail_reason = COALESCE(sqlc.narg('fail_reason'), fail_reason),
finished_at = COALESCE(sqlc.narg('finished_at'), finished_at),
bytes_transferred = COALESCE(sqlc.narg('bytes_transferred'), bytes_transferred)
WHERE id = sqlc.arg('id');
-- name: ExpirePendingSessions :many
UPDATE transfer_sessions
SET state = 'CANCELLED',
fail_reason = 'pending_timeout',
finished_at = sqlc.arg('finished_at')
WHERE state = 'PENDING' AND created_at < sqlc.arg('cutoff')
RETURNING id, user_id, sender_name, receiver_name;
+153
View File
@@ -0,0 +1,153 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: shortcut_tokens.sql
package db
import (
"context"
)
const countActiveShortcutTokensByUser = `-- name: CountActiveShortcutTokensByUser :one
SELECT COUNT(*) FROM shortcut_tokens
WHERE user_id = ? AND revoked = 0 AND expires_at > ?
`
type CountActiveShortcutTokensByUserParams struct {
UserID string `json:"user_id"`
ExpiresAt int64 `json:"expires_at"`
}
func (q *Queries) CountActiveShortcutTokensByUser(ctx context.Context, arg CountActiveShortcutTokensByUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countActiveShortcutTokensByUser, arg.UserID, arg.ExpiresAt)
var count int64
err := row.Scan(&count)
return count, err
}
const getShortcutToken = `-- name: GetShortcutToken :one
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE jti = ?
`
func (q *Queries) GetShortcutToken(ctx context.Context, jti string) (ShortcutToken, error) {
row := q.db.QueryRowContext(ctx, getShortcutToken, jti)
var i ShortcutToken
err := row.Scan(
&i.Jti,
&i.UserID,
&i.Label,
&i.Scopes,
&i.CreatedAt,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
)
return i, err
}
const insertShortcutToken = `-- name: InsertShortcutToken :exec
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`
type InsertShortcutTokenParams struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
Label string `json:"label"`
Scopes string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
}
// shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
// iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
// user_id to block cross-user revocation; Touch records last use asynchronously.
func (q *Queries) InsertShortcutToken(ctx context.Context, arg InsertShortcutTokenParams) error {
_, err := q.db.ExecContext(ctx, insertShortcutToken,
arg.Jti,
arg.UserID,
arg.Label,
arg.Scopes,
arg.CreatedAt,
arg.ExpiresAt,
)
return err
}
const listShortcutTokensByUser = `-- name: ListShortcutTokensByUser :many
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE user_id = ?
ORDER BY created_at DESC
`
func (q *Queries) ListShortcutTokensByUser(ctx context.Context, userID string) ([]ShortcutToken, error) {
rows, err := q.db.QueryContext(ctx, listShortcutTokensByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShortcutToken
for rows.Next() {
var i ShortcutToken
if err := rows.Scan(
&i.Jti,
&i.UserID,
&i.Label,
&i.Scopes,
&i.CreatedAt,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
); 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 revokeShortcutToken = `-- name: RevokeShortcutToken :execrows
UPDATE shortcut_tokens
SET revoked = 1
WHERE jti = ? AND user_id = ?
`
type RevokeShortcutTokenParams struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
}
func (q *Queries) RevokeShortcutToken(ctx context.Context, arg RevokeShortcutTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeShortcutToken, arg.Jti, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchShortcutTokenUsed = `-- name: TouchShortcutTokenUsed :exec
UPDATE shortcut_tokens
SET last_used_at = ?
WHERE jti = ?
`
type TouchShortcutTokenUsedParams struct {
LastUsedAt *int64 `json:"last_used_at"`
Jti string `json:"jti"`
}
func (q *Queries) TouchShortcutTokenUsed(ctx context.Context, arg TouchShortcutTokenUsedParams) error {
_, err := q.db.ExecContext(ctx, touchShortcutTokenUsed, arg.LastUsedAt, arg.Jti)
return err
}
+155
View File
@@ -0,0 +1,155 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: transfers.sql
package db
import (
"context"
)
const createTransferSession = `-- name: CreateTransferSession :exec
INSERT INTO transfer_sessions (
id, user_id, sender_name, receiver_name, state,
file_name, file_size, file_sha256, created_at
)
VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
`
type CreateTransferSessionParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SenderName string `json:"sender_name"`
ReceiverName string `json:"receiver_name"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
FileSha256 *string `json:"file_sha256"`
CreatedAt int64 `json:"created_at"`
}
func (q *Queries) CreateTransferSession(ctx context.Context, arg CreateTransferSessionParams) error {
_, err := q.db.ExecContext(ctx, createTransferSession,
arg.ID,
arg.UserID,
arg.SenderName,
arg.ReceiverName,
arg.FileName,
arg.FileSize,
arg.FileSha256,
arg.CreatedAt,
)
return err
}
const expirePendingSessions = `-- name: ExpirePendingSessions :many
UPDATE transfer_sessions
SET state = 'CANCELLED',
fail_reason = 'pending_timeout',
finished_at = ?1
WHERE state = 'PENDING' AND created_at < ?2
RETURNING id, user_id, sender_name, receiver_name
`
type ExpirePendingSessionsParams struct {
FinishedAt *int64 `json:"finished_at"`
Cutoff int64 `json:"cutoff"`
}
type ExpirePendingSessionsRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SenderName string `json:"sender_name"`
ReceiverName string `json:"receiver_name"`
}
func (q *Queries) ExpirePendingSessions(ctx context.Context, arg ExpirePendingSessionsParams) ([]ExpirePendingSessionsRow, error) {
rows, err := q.db.QueryContext(ctx, expirePendingSessions, arg.FinishedAt, arg.Cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ExpirePendingSessionsRow
for rows.Next() {
var i ExpirePendingSessionsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.SenderName,
&i.ReceiverName,
); 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 getTransferSession = `-- name: GetTransferSession :one
SELECT id, user_id, sender_name, receiver_name, state, mode,
file_name, file_size, file_sha256, bytes_transferred,
created_at, finished_at, fail_reason
FROM transfer_sessions
WHERE id = ?
`
func (q *Queries) GetTransferSession(ctx context.Context, id string) (TransferSession, error) {
row := q.db.QueryRowContext(ctx, getTransferSession, id)
var i TransferSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.SenderName,
&i.ReceiverName,
&i.State,
&i.Mode,
&i.FileName,
&i.FileSize,
&i.FileSha256,
&i.BytesTransferred,
&i.CreatedAt,
&i.FinishedAt,
&i.FailReason,
)
return i, err
}
const updateTransferState = `-- name: UpdateTransferState :execrows
UPDATE transfer_sessions
SET state = ?1,
mode = COALESCE(?2, mode),
fail_reason = COALESCE(?3, fail_reason),
finished_at = COALESCE(?4, finished_at),
bytes_transferred = COALESCE(?5, bytes_transferred)
WHERE id = ?6
`
type UpdateTransferStateParams struct {
State string `json:"state"`
Mode *string `json:"mode"`
FailReason *string `json:"fail_reason"`
FinishedAt *int64 `json:"finished_at"`
BytesTransferred *int64 `json:"bytes_transferred"`
ID string `json:"id"`
}
func (q *Queries) UpdateTransferState(ctx context.Context, arg UpdateTransferStateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateTransferState,
arg.State,
arg.Mode,
arg.FailReason,
arg.FinishedAt,
arg.BytesTransferred,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+247
View File
@@ -0,0 +1,247 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
// provider; the access_token still flows back to the browser unchanged.
type authConfigResp struct {
AuthMode string `json:"auth_mode"`
AuthorizeURL string `json:"authorize_url"`
// TokenURL is published so native clients (the desktop app) can run their
// own loopback PKCE flow directly against the provider, reusing this same
// client_id. The browser doesn't need it — it proxies through /api/auth/exchange.
TokenURL string `json:"token_url"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Scopes string `json:"scopes"`
}
// handleAuthConfig publishes everything the frontend needs to start the PKCE
// authorize redirect. No auth required (it's all public OIDC metadata).
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authConfigResp{
AuthMode: s.cfg.AuthMode,
AuthorizeURL: s.cfg.OIDCAuthorizeURL,
TokenURL: s.cfg.OIDCTokenURL,
ClientID: s.cfg.OIDCClientID,
RedirectURI: s.cfg.OIDCRedirectURI,
Scopes: s.cfg.OIDCScopes,
})
}
type exchangeReq struct {
Code string `json:"code"`
CodeVerifier string `json:"code_verifier"`
}
type tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type userResp struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
type exchangeResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
}
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.Code == "" || req.CodeVerifier == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", req.Code)
form.Set("code_verifier", req.CodeVerifier)
form.Set("client_id", s.cfg.OIDCClientID)
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
// Casdoor's "cdrop" application is a confidential client; PKCE is layered
// on top of the standard secret. Skip the secret if not configured to keep
// public-client OIDC providers happy.
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("oidc exchange failed", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
user, err := extractUser(tr.IDToken, tr.AccessToken)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, exchangeResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
User: user,
})
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
type refreshResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.RefreshToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", req.RefreshToken)
form.Set("client_id", s.cfg.OIDCClientID)
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("oidc refresh failed", "err", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
})
}
var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}
func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := oidcHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("oidc unreachable: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var tr tokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("oidc malformed: %w", err)
}
if tr.AccessToken == "" {
return nil, fmt.Errorf("oidc returned no access_token")
}
return &tr, nil
}
// extractUser parses {sub, preferred_username | name} from the id_token if
// available, else falls back to access_token. We DON'T verify the signature
// here — the backend's auth middleware verifies access_token on every API
// call via the JWKS cache, so any tamper would surface there.
func extractUser(idToken, accessToken string) (userResp, error) {
pick := idToken
if pick == "" {
pick = accessToken
}
parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{
jose.RS256, jose.RS384, jose.RS512,
jose.ES256, jose.ES384, jose.ES512,
jose.HS256,
})
if err != nil {
return userResp{}, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
return userResp{}, err
}
u := userResp{ID: std.Subject}
if v, ok := custom["preferred_username"].(string); ok && v != "" {
u.Name = v
} else if v, ok := custom["name"].(string); ok && v != "" {
u.Name = v
} else if v, ok := custom["email"].(string); ok && v != "" {
u.Name = v
} else {
u.Name = u.ID
}
// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段;
// 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。
if v, ok := custom["avatar"].(string); ok && v != "" {
u.Avatar = v
} else if v, ok := custom["picture"].(string); ok && v != "" {
u.Avatar = v
}
return u, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+30
View File
@@ -0,0 +1,30 @@
package httpapi
import (
"log/slog"
"net/http"
"commilitia.net/cdrop/internal/calls"
)
// fallbackICEServers is what we return when Cloudflare is not configured or
// generation fails: STUN-only (Cloudflare anycast, China-friendly).
var fallbackICEServers = []calls.ICEServer{
{URLs: []string{"stun:stun.cloudflare.com:3478"}},
}
// handleCallsCredentials returns ICE servers for the WebRTC client.
// Auth-required (don't expose Cloudflare creds to anonymous probes).
func (s *Server) handleCallsCredentials(w http.ResponseWriter, r *http.Request) {
if s.calls == nil {
writeJSON(w, http.StatusOK, map[string]any{"iceServers": fallbackICEServers})
return
}
creds, err := s.calls.Get(r.Context())
if err != nil {
slog.Warn("cf turn credentials unavailable; serving fallback STUN", "err", err)
writeJSON(w, http.StatusOK, map[string]any{"iceServers": fallbackICEServers})
return
}
writeJSON(w, http.StatusOK, creds)
}
+154
View File
@@ -0,0 +1,154 @@
package httpapi
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"commilitia.net/cdrop/internal/clipboard"
"commilitia.net/cdrop/internal/jwtauth"
)
type clipboardPutReq struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
// OriginTs is the source device's copy time (ms). It's the LWW key: the write
// is accepted only if it strictly exceeds the stored one. Clients that omit it
// (legacy / transitional) get server-now, so they still sync.
OriginTs int64 `json:"origin_ts"`
}
// handleClipboardPut 接受一次剪贴板写入。请求体最大字节限制(MaxBytesReader
// 给上限 + JSON 信封冗余 (×1.5);服务层会再核一次纯内容字节数。
func (s *Server) handleClipboardPut(w http.ResponseWriter, r *http.Request) {
if s.clipboard == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
maxBody := int64(s.clipboard.MaxBytes() + s.clipboard.MaxBytes()/2 + 256)
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
var req clipboardPutReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// MaxBytesReader 触发时返回 *http.MaxBytesError;统一映射 413。
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body too large"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
originTs := req.OriginTs
if originTs <= 0 {
originTs = time.Now().UnixMilli() // client didn't tag a copy time → use now
}
st, accepted, err := s.clipboard.Put(r.Context(), claims.UserID, req.ContentType, req.Content, device, originTs)
switch {
case errors.Is(err, clipboard.ErrUnsupportedType):
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": err.Error()})
return
case errors.Is(err, clipboard.ErrTooLarge):
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": err.Error()})
return
case err != nil:
slog.Error("clipboard put failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
w.Header().Set("ETag", etagFor(st.Version))
if !accepted {
// LWW rejected this write — a newer copy already won. Hand back the current
// winner so the client reconciles (pulls) instead of clobbering it.
writeJSON(w, http.StatusConflict, st)
return
}
writeJSON(w, http.StatusAccepted, st)
}
type clipboardMeta struct {
Version int64 `json:"version"`
SourceDevice string `json:"source_device"`
ContentType string `json:"content_type"`
UpdatedAt int64 `json:"updated_at"`
OriginTs int64 `json:"origin_ts"`
}
// handleClipboardVersion 返回剪贴板版本元信息(不含 content)。给读不了 HTTP 状态
// 码的轮询客户端(iOS 快捷指令)做廉价变更探测:版本变了、且来源不是自己,才去
// GET 全量内容——避免每轮都全量拉取。
func (s *Server) handleClipboardVersion(w http.ResponseWriter, r *http.Request) {
if s.clipboard == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
st, err := s.clipboard.Get(r.Context(), claims.UserID)
if errors.Is(err, sql.ErrNoRows) {
st = &clipboard.State{ContentType: clipboard.ContentTypeText}
} else if err != nil {
slog.Error("clipboard version get failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
etag := etagFor(st.Version)
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeJSON(w, http.StatusOK, clipboardMeta{
Version: st.Version,
SourceDevice: st.SourceDevice,
ContentType: st.ContentType,
UpdatedAt: st.UpdatedAt,
OriginTs: st.OriginTs,
})
}
// handleClipboardGet 返回当前剪贴板内容;支持 If-None-Match: <updated_at>
// → 304 节省流量。用户尚未写入时返回 200 + 空 State + ETag "0",让"槽位常驻"
// 语义对前端更友好(避免 DevTools Network 红色 404)。
func (s *Server) handleClipboardGet(w http.ResponseWriter, r *http.Request) {
if s.clipboard == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
st, err := s.clipboard.Get(r.Context(), claims.UserID)
if errors.Is(err, sql.ErrNoRows) {
st = &clipboard.State{ContentType: clipboard.ContentTypeText}
} else if err != nil {
slog.Error("clipboard get failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
etag := etagFor(st.Version)
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
writeJSON(w, http.StatusOK, st)
}
// etagFor 用版本号的十进制字符串当 ETag——版本是严格单调的,故 ETag 精确反映
// 「内容变了没有」(秒级 updated_at 会在同秒两写时撞号)。RFC 9110 的强 ETag。
func etagFor(version int64) string {
return fmt.Sprintf("\"%s\"", strconv.FormatInt(version, 10))
}
+72
View File
@@ -0,0 +1,72 @@
package httpapi
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
type deviceItem struct {
Name string `json:"name"`
Type string `json:"type"`
Online bool `json:"online"`
LastSeen int64 `json:"last_seen"`
}
// handleDevices returns the calling user's known devices with live online flags.
// Online = currently connected to /api/events; LastSeen = epoch from devices.last_seen.
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID)
if err != nil {
slog.Error("list devices failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
out := make([]deviceItem, 0, len(devs))
for _, d := range devs {
out = append(out, deviceItem{
Name: d.Name,
Type: d.Type,
Online: s.hub.Online(claims.UserID, d.Name),
LastSeen: d.LastSeen,
})
}
writeJSON(w, http.StatusOK, map[string]any{"devices": out})
}
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
name := chi.URLParam(r, "name")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device name"})
return
}
rows, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
UserID: claims.UserID,
Name: name,
})
if err != nil {
slog.Error("delete device failed", "err", err, "user", claims.UserID, "name", name)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if rows == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"})
return
}
s.hub.Kick(claims.UserID, name)
s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent)
}
+82
View File
@@ -0,0 +1,82 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"time"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
// 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, 410 if peer offline, 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 {
writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"})
return
}
w.WriteHeader(http.StatusNoContent)
}
+155
View File
@@ -0,0 +1,155 @@
package httpapi
import (
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/relay"
)
// Per-chunk upload limit. Senders split files into chunks of at most this size
// (plan §M5: "sender 多次 chunked POST"). Whole-chunk read into memory before
// commit lets us cleanly reject oversized chunks without partial state.
const maxRelayChunkSize = relay.DefaultChunkLimit
func (s *Server) handleRelayChunk(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
sessionID := chi.URLParam(r, "id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing session id"})
return
}
// Read body fully so an oversized chunk fails *before* we touch the ring.
body, err := io.ReadAll(io.LimitReader(r.Body, maxRelayChunkSize+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if len(body) > maxRelayChunkSize {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "chunk exceeds 1 MiB"})
return
}
sess, err := s.relay.Acquire(sessionID, claims.UserID, device)
if err != nil {
mapRelayError(w, err)
return
}
// Resume support (plan §M10): caller sends the offset of THIS chunk's
// first byte. We CAS against current bytesWritten so a retried chunk
// (network blip, sender app reload) returns 409 and the X-Bytes-Received
// header tells the client where to pick up.
if hdr := r.Header.Get("X-Resume-Offset"); hdr != "" {
want, perr := strconv.ParseInt(hdr, 10, 64)
if perr != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid X-Resume-Offset"})
return
}
got := sess.BytesWritten()
if want != got {
w.Header().Set("X-Bytes-Received", strconv.FormatInt(got, 10))
writeJSON(w, http.StatusConflict, map[string]any{
"error": "offset mismatch",
"expected": got,
"received": want,
})
return
}
}
n, err := sess.Write(r.Context(), body)
if err != nil {
slog.Warn("relay chunk write failed",
"err", err, "session", sessionID, "user", claims.UserID, "n", n)
writeJSON(w, http.StatusGone, map[string]string{
"error": err.Error(),
"bytes_received": strconv.FormatInt(sess.BytesWritten(), 10),
})
return
}
if r.Header.Get("X-End") == "true" {
sess.CloseWriter()
}
w.Header().Set("X-Bytes-Received", strconv.FormatInt(sess.BytesWritten(), 10))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRelayStream(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
sessionID := chi.URLParam(r, "id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing session id"})
return
}
sess, err := s.relay.Acquire(sessionID, claims.UserID, device)
if err != nil {
mapRelayError(w, err)
return
}
// The relay stream is strictly single-consumer: a second concurrent reader
// would drain bytes out of the ring and silently corrupt the receiver's copy
// (G2). Refuse it rather than join. ReleaseReader on return lets a legitimate
// reconnect (network blip, page reload) take over once the first has exited.
if !sess.AcquireReader() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "stream already active"})
return
}
defer sess.ReleaseReader()
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
if flusher != nil {
flusher.Flush()
}
buf := make([]byte, 32*1024)
for {
n, err := sess.Read(r.Context(), buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
if flusher != nil {
flusher.Flush()
}
}
if errors.Is(err, io.EOF) {
return
}
if err != nil {
return
}
}
}
func mapRelayError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, relay.ErrTooManySessions):
writeJSON(w, http.StatusServiceUnavailable,
map[string]string{"error": "too many active relay sessions"})
case errors.Is(err, relay.ErrForbidden):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
case errors.Is(err, relay.ErrNotRegistered):
// The transfer isn't in relay mode (never fell back, or the session was
// reaped after going idle). The client should re-issue /fallback.
writeJSON(w, http.StatusConflict, map[string]string{"error": "relay session not active"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
+202
View File
@@ -0,0 +1,202 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/relay"
"commilitia.net/cdrop/internal/transfer"
"commilitia.net/cdrop/internal/webui"
)
type Server struct {
cfg *config.Config
db *sql.DB
queries *db.Queries
auth *jwtauth.Authenticator
hub *hub.Hub
transfers *transfer.Service
relay *relay.Manager
calls *calls.Provider // optional; nil → STUN-only fallback
clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard
mux *chi.Mux
}
func New(
cfg *config.Config,
dbConn *sql.DB,
queries *db.Queries,
auth *jwtauth.Authenticator,
h *hub.Hub,
transfers *transfer.Service,
rm *relay.Manager,
cp *calls.Provider,
clip *clipboard.Service,
) *Server {
s := &Server{
cfg: cfg,
db: dbConn,
queries: queries,
auth: auth,
hub: h,
transfers: transfers,
relay: rm,
calls: cp,
clipboard: clip,
mux: chi.NewRouter(),
}
s.routes()
return s
}
func (s *Server) Handler() http.Handler { return s.mux }
func (s *Server) routes() {
s.mux.Use(middleware.RequestID)
s.mux.Use(middleware.RealIP)
s.mux.Use(middleware.Recoverer)
s.mux.Get("/healthz", s.handleHealth)
// Anything not matched below falls through to the embedded frontend bundle.
// In Docker the binary serves the SPA itself; in local dev the embed FS is
// empty and this 404s — vite dev on :5173 handles the UI instead.
s.mux.NotFound(webui.Handler().ServeHTTP)
s.mux.Route("/api", func(r chi.Router) {
// Public OIDC PKCE plumbing — no auth required (it bootstraps it).
r.Get("/auth/config", s.handleAuthConfig)
// Rate-limit the credential-bearing OAuth endpoints (G4): they proxy to
// the IdP, so without a cap cdrop is a brute-force / token-pivot relay.
// Per-IP (RealIP is mounted above); 60/min is ample for real logins and
// hourly refresh even behind a shared NAT, while choking a scripted flood.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(60, time.Minute))
r.Post("/auth/exchange", s.handleAuthExchange)
r.Post("/auth/refresh", s.handleAuthRefresh)
})
// Protected routes. gzip / compress is intentionally NOT mounted —
// it would buffer the SSE stream.
r.Group(func(r chi.Router) {
r.Use(s.auth.Middleware)
// Clipboard is the one capability a scoped shortcut token may reach
// (iOS Shortcut sync) — it must carry the "clipboard" scope. Full
// login sessions pass requireScope unconditionally.
r.Group(func(r chi.Router) {
r.Use(requireScope(shortcutScope))
r.Get("/clipboard", s.handleClipboardGet)
r.Put("/clipboard", s.handleClipboardPut)
// Lightweight version probe — no content; lets pollers (iOS
// Shortcut) detect change cheaply before fetching the full body.
r.Get("/clipboard/version", s.handleClipboardVersion)
})
// Everything else requires a full login session; scoped shortcut
// tokens are rejected, so a leaked token's blast radius stays the
// clipboard and nothing more (including token self-management).
r.Group(func(r chi.Router) {
r.Use(rejectScoped)
r.Get("/me", s.handleMe)
r.Post("/me/disconnect", s.handleDisconnect)
r.Get("/hub/events", s.handleEvents)
r.Post("/hub/signal", s.handleSignal)
r.Post("/message", s.handleMessage)
r.Get("/devices", s.handleDevices)
r.Delete("/devices/{name}", s.handleDeleteDevice)
r.Get("/calls/credentials", s.handleCallsCredentials)
r.Post("/shortcut/issue", s.handleShortcutIssue)
r.Get("/shortcut", s.handleShortcutList)
r.Delete("/shortcut/{jti}", s.handleShortcutRevoke)
r.Route("/transfer", func(r chi.Router) {
r.Post("/initiate", s.handleTransferInit)
r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, ""))
r.Post("/{id}/cancel", s.transitionHandler(transfer.StateCancelled, ""))
r.Post("/{id}/p2p", s.transitionHandler(transfer.StateP2PActive, transfer.ModeP2P))
r.Post("/{id}/fallback", s.transitionHandler(transfer.StateRelayActive, transfer.ModeRelay))
r.Post("/{id}/done", s.transitionHandler(transfer.StateDone, ""))
r.Post("/{id}/fail", s.transitionHandler(transfer.StateFailed, ""))
})
r.Route("/relay/{id}", func(r chi.Router) {
r.Post("/chunk", s.handleRelayChunk)
r.Get("/stream", s.handleRelayStream)
})
})
})
})
}
type healthResp struct {
Status string `json:"status"`
AuthMode string `json:"auth_mode"`
DBOK bool `json:"db_ok"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
resp := healthResp{Status: "ok", AuthMode: s.cfg.AuthMode, DBOK: true}
if err := s.db.PingContext(ctx); err != nil {
resp.Status = "degraded"
resp.DBOK = false
slog.Error("healthz db ping failed", "err", err)
}
writeJSON(w, http.StatusOK, resp)
}
type meResp struct {
UserID string `json:"user_id"`
Groups []string `json:"groups"`
DeviceName string `json:"device_name"`
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
device, _ := jwtauth.DeviceNameFromContext(r.Context())
writeJSON(w, http.StatusOK, meResp{
UserID: claims.UserID,
Groups: claims.Groups,
DeviceName: device,
})
}
// handleDisconnect 是用户主动告知"本机要下线"的轻量信号(登出 / 关页前)。
// auth 中间件会先 UPSERT device 把 last_seen 更新,本 handler 再 Kick 关闭
// SSE + 立刻广播 presence,让对端不必等 30s 宽限期就看到本设备离线。
func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
deviceName, _ := jwtauth.DeviceNameFromContext(r.Context())
if deviceName == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"})
return
}
s.hub.Kick(claims.UserID, deviceName)
s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+240
View File
@@ -0,0 +1,240 @@
package httpapi
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// shortcutScope is the single scope a shortcut token is granted; the route layer
// only lets a token carrying it reach the clipboard endpoints (see server.go).
const shortcutScope = "clipboard"
// requireScope gates a route group that scoped shortcut tokens may also reach: a
// scoped token must carry the named scope, while a full login session always
// passes (it has no scope restriction).
func requireScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Scoped() && !claims.HasScope(scope) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"})
return
}
next.ServeHTTP(w, r)
})
}
}
// rejectScoped blocks scoped shortcut tokens outright — for every route only a
// full login session may touch. A leaked clipboard token thus reaches nothing
// here: not the device list, transfers, signalling, nor token self-management.
func rejectScoped(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Scoped() {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"})
return
}
next.ServeHTTP(w, r)
})
}
// 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token
// 防止泄漏的快捷指令 token 自我续期或越权。
type shortcutIssueReq struct {
Label string `json:"label"`
}
type shortcutIssueResp struct {
// Token 仅在签发时返回这一次,服务端不留可还原副本(只存 jti 等元数据)。
Token string `json:"token"`
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
ExpiresAt int64 `json:"expires_at"`
}
type shortcutTokenView struct {
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt *int64 `json:"last_used_at"`
Revoked bool `json:"revoked"`
}
// handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores
// its metadata, and returns the token string once. Disabled (503) when no HS256
// secret is configured.
func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) {
if len(s.cfg.HS256Secret) == 0 {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
var req shortcutIssueReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
label := strings.TrimSpace(req.Label)
if label == "" {
label = "iOS Shortcut"
}
if len(label) > 64 {
label = label[:64]
}
now := time.Now()
active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{
UserID: claims.UserID,
ExpiresAt: now.Unix(),
})
if err != nil {
slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if active >= int64(s.cfg.ShortcutMaxPerUser) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"})
return
}
jti, err := randomTokenID()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour)
token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires)
if err != nil {
slog.Error("sign shortcut token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"})
return
}
if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
Label: label,
Scopes: shortcutScope,
CreatedAt: now.Unix(),
ExpiresAt: expires.Unix(),
}); err != nil {
slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
writeJSON(w, http.StatusCreated, shortcutIssueResp{
Token: token,
Jti: jti,
Label: label,
Scopes: []string{shortcutScope},
ExpiresAt: expires.Unix(),
})
}
// handleShortcutList returns the caller's tokens (metadata only, never the token
// string).
func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID)
if err != nil {
slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
out := make([]shortcutTokenView, 0, len(rows))
for _, t := range rows {
out = append(out, shortcutTokenView{
Jti: t.Jti,
Label: t.Label,
Scopes: strings.Fields(t.Scopes),
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
Revoked: t.Revoked != 0,
})
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}
// handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so
// one user can't revoke another's). verifyHS256 reads the flag on every request,
// so revocation takes effect on the token's next use.
func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
jti := chi.URLParam(r, "jti")
if jti == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"})
return
}
n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
})
if err != nil {
slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if n == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space-
// delimited OAuth-style, informational — the store row is authoritative), iat,
// exp. The shared HS256 secret is the same one verifyHS256 checks against.
func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) {
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
return "", err
}
std := jwt.Claims{
Subject: userID,
ID: jti,
IssuedAt: jwt.NewNumericDate(iat),
Expiry: jwt.NewNumericDate(exp),
}
return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
}
// randomTokenID returns a 128-bit URL-safe random id for the jti.
func randomTokenID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
+63
View File
@@ -0,0 +1,63 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
// maxSignalBytes caps the signaling body. WebRTC SDP offers/answers and bundled
// ICE candidates are at most a few KB; the payload is an opaque json.RawMessage
// with no other length check, so without this a single request could stream an
// unbounded body into memory (R4). 64 KiB leaves generous headroom for fat SDP.
const maxSignalBytes = 64 * 1024
type signalReq struct {
To string `json:"to"`
Payload json.RawMessage `json:"payload"`
}
// handleSignal forwards WebRTC offer/answer/ICE candidates between same-user devices.
// Body: { to: deviceName, payload: any-json }
// 204 if delivered to live SSE; 410 if peer offline.
func (s *Server) handleSignal(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
from, _ := jwtauth.DeviceNameFromContext(r.Context())
r.Body = http.MaxBytesReader(w, r.Body, maxSignalBytes)
var req signalReq
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": "signal payload too large"})
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.To == from {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot signal self"})
return
}
delivered := s.hub.SendTo(claims.UserID, req.To, hub.Event{
Type: "signal",
Data: map[string]any{
"from": from,
"payload": req.Payload,
},
})
if !delivered {
writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"})
return
}
w.WriteHeader(http.StatusNoContent)
}
+100
View File
@@ -0,0 +1,100 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
const (
keepaliveInterval = 15 * time.Second
probeFrame = ": hi\n\n"
)
// handleEvents serves the long-lived SSE channel mandated by brief §5.
// Sets X-Accel-Buffering / Cache-Control / flush to defeat reverse-proxy buffering.
// Sends a probe frame immediately so the client can detect first-byte latency.
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "missing claims", http.StatusUnauthorized)
return
}
deviceName, _ := jwtauth.DeviceNameFromContext(r.Context())
if deviceName == "" {
http.Error(w, "missing device", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, probeFrame); err != nil {
return
}
flusher.Flush()
client := s.hub.Connect(r.Context(), claims.UserID, deviceName)
defer s.hub.Disconnect(client)
slog.Debug("sse connected", "user", claims.UserID, "device", deviceName)
ping := time.NewTicker(keepaliveInterval)
defer ping.Stop()
for {
select {
case ev, ok := <-client.Events():
if !ok {
return
}
if err := writeSSEEvent(w, ev); err != nil {
return
}
flusher.Flush()
case <-ping.C:
if _, err := io.WriteString(w, "event: ping\ndata: {}\n\n"); err != nil {
return
}
flusher.Flush()
// Refresh last_seen so a connection-only client (no other API calls)
// keeps its device row alive past TTL — matches the sliding
// refresh_token semantics requested for device persistence.
_ = s.queries.UpsertDevice(r.Context(), db.UpsertDeviceParams{
UserID: claims.UserID,
Name: deviceName,
Type: jwtauth.DeviceTypeFromContext(r.Context()),
LastSeen: time.Now().Unix(),
})
case <-r.Context().Done():
return
}
}
}
func writeSSEEvent(w io.Writer, ev hub.Event) error {
payload, err := json.Marshal(ev.Data)
if err != nil {
return fmt.Errorf("marshal sse data: %w", err)
}
// SSE frame: "event: <type>\ndata: <json>\n\n".
// JSON is single-line via json.Marshal so no `data:` continuation needed.
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, payload)
return err
}
+94
View File
@@ -0,0 +1,94 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/transfer"
)
type initReq struct {
ReceiverName string `json:"receiver_name"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
FileSHA256 string `json:"file_sha256,omitempty"`
}
type initResp struct {
SessionID string `json:"session_id"`
}
func (s *Server) handleTransferInit(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
sender, _ := jwtauth.DeviceNameFromContext(r.Context())
var req initReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
id, err := s.transfers.Init(r.Context(), transfer.InitParams{
UserID: claims.UserID,
SenderName: sender,
ReceiverName: req.ReceiverName,
FileName: req.FileName,
FileSize: req.FileSize,
FileSHA256: req.FileSHA256,
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, initResp{SessionID: id})
}
// handleTransferTransition is the shared body for accept / cancel / fallback /
// done / fail. The target state and any side-fields are bound by the route.
type transitionReq struct {
Reason string `json:"reason,omitempty"`
BytesTransferred int64 `json:"bytes_transferred,omitempty"`
}
func (s *Server) transitionHandler(target, mode string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
return
}
var req transitionReq
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
}
err := s.transfers.Transition(
r.Context(), claims.UserID, id, target,
mode, req.Reason, req.BytesTransferred,
)
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, transfer.ErrNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, transfer.ErrForbidden):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
case errors.Is(err, transfer.ErrInvalidTransition):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
case errors.Is(err, transfer.ErrRelayUnavailable):
writeJSON(w, http.StatusServiceUnavailable,
map[string]string{"error": "relay unavailable, retry later"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
}
+242
View File
@@ -0,0 +1,242 @@
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)
}
}
}
+198
View File
@@ -0,0 +1,198 @@
package hub
import (
"context"
"sync/atomic"
"testing"
"time"
"commilitia.net/cdrop/internal/db"
)
// fakeLister returns a fixed device list and counts calls.
type fakeLister struct {
devices []db.Device
calls atomic.Int32
}
func (f *fakeLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) {
f.calls.Add(1)
return f.devices, nil
}
func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) Event {
t.Helper()
select {
case ev, ok := <-c.Events():
if !ok {
t.Fatalf("event channel closed; wanted type=%q", want)
}
if want != "" && ev.Type != want {
t.Fatalf("event type: got %q, want %q", ev.Type, want)
}
return ev
case <-time.After(timeout):
t.Fatalf("timeout waiting for event type=%q", want)
}
return Event{}
}
func TestConnectFiresPresenceEvent(t *testing.T) {
lister := &fakeLister{
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}},
}
h := New(lister)
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1")
defer h.Disconnect(c)
ev := waitForEvent(t, c, "presence", time.Second)
data, _ := ev.Data.(map[string]any)
devs, _ := data["devices"].([]PresenceDevice)
if len(devs) != 1 || devs[0].Name != "tab-1" || !devs[0].Online {
t.Errorf("presence devices: %+v", devs)
}
}
func TestSecondClientSeesFirstAsOnline(t *testing.T) {
lister := &fakeLister{
devices: []db.Device{
{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1},
{UserID: "alice", Name: "tab-2", Type: "browser", LastSeen: 2},
},
}
h := New(lister)
defer h.Close()
c1 := h.Connect(context.Background(), "alice", "tab-1")
defer h.Disconnect(c1)
_ = waitForEvent(t, c1, "presence", time.Second)
c2 := h.Connect(context.Background(), "alice", "tab-2")
defer h.Disconnect(c2)
// c2 receives its own presence (announced on Connect).
ev := waitForEvent(t, c2, "presence", time.Second)
devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
if len(devs) != 2 {
t.Fatalf("expected 2 devices in presence, got %+v", devs)
}
online := map[string]bool{}
for _, d := range devs {
online[d.Name] = d.Online
}
if !online["tab-1"] || !online["tab-2"] {
t.Errorf("both tabs should be online: %+v", online)
}
// c1 also gets presence diff for c2's arrival.
ev = waitForEvent(t, c1, "presence", time.Second)
devs, _ = ev.Data.(map[string]any)["devices"].([]PresenceDevice)
if len(devs) != 2 {
t.Errorf("c1 presence after tab-2 arrival: got %d devices, want 2", len(devs))
}
}
func TestSendToRoutesEvent(t *testing.T) {
lister := &fakeLister{}
h := New(lister)
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1")
defer h.Disconnect(c)
_ = waitForEvent(t, c, "presence", time.Second)
if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) {
t.Fatal("SendTo to live client should report true")
}
ev := waitForEvent(t, c, "signal", time.Second)
if ev.Data.(map[string]any)["x"] != 1 {
t.Errorf("payload: %+v", ev.Data)
}
}
func TestSendToOfflineDeviceReportsFalse(t *testing.T) {
h := New(&fakeLister{})
defer h.Close()
if h.SendTo("ghost-user", "ghost-device", Event{Type: "signal"}) {
t.Error("SendTo to offline target should report false")
}
}
func TestReconnectClosesOldChannel(t *testing.T) {
h := New(&fakeLister{})
defer h.Close()
old := h.Connect(context.Background(), "alice", "tab-1")
// drain the initial presence so we can detect close
<-old.Events()
_ = h.Connect(context.Background(), "alice", "tab-1")
select {
case _, ok := <-old.Events():
if ok {
t.Error("old channel should be closed on reconnect from same (user, device)")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for old channel close")
}
}
func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
h := New(&fakeLister{
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser"}},
})
defer h.Close()
c := h.Connect(context.Background(), "alice", "tab-1")
if !h.Online("alice", "tab-1") {
t.Fatal("client should be online after Connect")
}
h.Disconnect(c)
if h.Online("alice", "tab-1") {
t.Error("client should not be online after Disconnect")
}
}
// TestGracePeriod uses a tiny grace so the test runs fast.
func TestGracePeriodDelaysOfflinePresence(t *testing.T) {
h := New(&fakeLister{
devices: []db.Device{
{UserID: "alice", Name: "tab-1", Type: "browser"},
{UserID: "alice", Name: "tab-2", Type: "browser"},
},
})
h.grace = 100 * time.Millisecond
defer h.Close()
c1 := h.Connect(context.Background(), "alice", "tab-1")
defer h.Disconnect(c1)
c2 := h.Connect(context.Background(), "alice", "tab-2")
// Drain initial presence frames
_ = waitForEvent(t, c1, "presence", time.Second)
_ = waitForEvent(t, c1, "presence", time.Second)
_ = waitForEvent(t, c2, "presence", time.Second)
h.Disconnect(c2)
// Grace 100ms: c1 should receive presence after the grace window with tab-2 offline.
select {
case ev := <-c1.Events():
if ev.Type != "presence" {
t.Fatalf("expected presence after grace, got %q", ev.Type)
}
devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
var tab2Online bool
for _, d := range devs {
if d.Name == "tab-2" {
tab2Online = d.Online
}
}
if tab2Online {
t.Error("tab-2 should be offline in post-grace presence")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for post-grace presence")
}
}
+56
View File
@@ -0,0 +1,56 @@
package jwtauth
import "context"
type Claims struct {
UserID string
Groups []string
// JTI and Scopes are populated only for HS256 shortcut tokens; a full OIDC /
// dev session leaves them empty. A non-empty JTI marks a *scoped* token —
// one allowed to reach only the endpoints its Scopes grant (the route layer
// enforces this). This keeps a leaked shortcut token's blast radius minimal.
JTI string
Scopes []string
}
// Scoped reports whether these claims came from a scoped shortcut token rather
// than a full login session.
func (c *Claims) Scoped() bool { return c.JTI != "" }
// HasScope reports whether the claims grant the named scope.
func (c *Claims) HasScope(scope string) bool {
for _, s := range c.Scopes {
if s == scope {
return true
}
}
return false
}
type ctxKey int
const (
claimsCtxKey ctxKey = iota
deviceCtxKey
deviceTypeCtxKey
)
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsCtxKey).(*Claims)
return c, ok
}
func DeviceNameFromContext(ctx context.Context) (string, bool) {
n, ok := ctx.Value(deviceCtxKey).(string)
return n, ok
}
// DeviceTypeFromContext returns the client-declared device type set by the auth
// middleware (browser / macos / windows / linux), defaulting to "browser".
func DeviceTypeFromContext(ctx context.Context) string {
t, ok := ctx.Value(deviceTypeCtxKey).(string)
if !ok || t == "" {
return "browser"
}
return t
}
+38
View File
@@ -0,0 +1,38 @@
package jwtauth
import (
"context"
"log/slog"
"time"
"commilitia.net/cdrop/internal/db"
)
// DeviceSweepInterval is how often the device sweeper wakes up.
const DeviceSweepInterval = 1 * time.Hour
// RunDeviceSweeper deletes devices whose last_seen is older than ttl.
// Per user requirement: device registration must not outlive the longest
// valid refresh_token (brief §2: 196h sliding window). Anything ≤ TTL is fine;
// 0 disables the sweeper.
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
if ttl <= 0 {
slog.Info("device sweeper disabled (ttl <= 0)")
return
}
slog.Info("device sweeper running", "ttl", ttl, "interval", DeviceSweepInterval)
t := time.NewTicker(DeviceSweepInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
cutoff := time.Now().Add(-ttl).Unix()
if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil {
slog.Error("device sweeper failed", "err", err)
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package jwtauth
import (
"context"
"path/filepath"
"testing"
"time"
"commilitia.net/cdrop/internal/db"
)
func openTestQueries(t *testing.T) *db.Queries {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "x.db")
conn, err := db.Open(dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
return db.New(conn)
}
func TestDeleteStaleDevices_RemovesOnlyOld(t *testing.T) {
q := openTestQueries(t)
ctx := context.Background()
now := time.Now()
old := now.Add(-200 * time.Hour)
fresh := now.Add(-1 * time.Hour)
upsert := func(name string, ts time.Time) {
if err := q.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "alice", Name: name, Type: "browser", LastSeen: ts.Unix(),
}); err != nil {
t.Fatalf("upsert %s: %v", name, err)
}
}
upsert("old-device", old)
upsert("fresh-device", fresh)
// Cutoff = "older than 196 hours from now" mimics RunDeviceSweeper math.
cutoff := now.Add(-196 * time.Hour).Unix()
if err := q.DeleteStaleDevices(ctx, cutoff); err != nil {
t.Fatalf("delete: %v", err)
}
devs, _ := q.ListDevicesByUser(ctx, "alice")
got := map[string]bool{}
for _, d := range devs {
got[d.Name] = true
}
if got["old-device"] {
t.Error("old-device should have been swept")
}
if !got["fresh-device"] {
t.Error("fresh-device should remain")
}
}
+113
View File
@@ -0,0 +1,113 @@
package jwtauth
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-jose/go-jose/v4"
)
// jwksCache fetches & caches the Casdoor JWKS.
//
// Behavior:
// - successful fetch: refreshes the entire key set
// - kid miss: forces a single refresh attempt
// - stale-while-error: if cached entry exists, return it even when refresh fails
type jwksCache struct {
url string
ttl time.Duration
mu sync.RWMutex
keys map[string]*rsa.PublicKey
fetchedAt time.Time
httpClient *http.Client
}
func newJWKSCache(url string, ttl time.Duration) *jwksCache {
return &jwksCache{
url: url,
ttl: ttl,
keys: map[string]*rsa.PublicKey{},
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
func (j *jwksCache) GetKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
if kid == "" {
return nil, errors.New("missing kid")
}
j.mu.RLock()
cached, found := j.keys[kid]
fresh := !j.fetchedAt.IsZero() && time.Since(j.fetchedAt) < j.ttl
j.mu.RUnlock()
if found && fresh {
return cached, nil
}
if err := j.refresh(ctx); err != nil {
if found {
slog.Warn("jwks refresh failed; returning stale key",
"err", err, "kid", kid)
return cached, nil
}
return nil, fmt.Errorf("jwks refresh: %w", err)
}
j.mu.RLock()
defer j.mu.RUnlock()
if k, ok := j.keys[kid]; ok {
return k, nil
}
return nil, fmt.Errorf("kid %q not found in JWKS", kid)
}
func (j *jwksCache) refresh(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.url, nil)
if err != nil {
return err
}
resp, err := j.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var set jose.JSONWebKeySet
if err := json.Unmarshal(body, &set); err != nil {
return fmt.Errorf("parse JWKS: %w", err)
}
next := map[string]*rsa.PublicKey{}
for _, k := range set.Keys {
pk, ok := k.Key.(*rsa.PublicKey)
if !ok || k.KeyID == "" {
continue
}
next[k.KeyID] = pk
}
j.mu.Lock()
defer j.mu.Unlock()
j.keys = next
j.fetchedAt = time.Now()
return nil
}
+319
View File
@@ -0,0 +1,319 @@
package jwtauth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
)
// Store is the subset of *db.Queries the auth middleware uses: device upserts
// plus the shortcut-token lookups needed to honour revocation. Declared as an
// interface so tests can swap in a fake.
type Store interface {
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
}
type Authenticator struct {
cfg *config.Config
store Store
jwks *jwksCache
hsKey []byte
}
func New(cfg *config.Config, store Store) *Authenticator {
a := &Authenticator{
cfg: cfg,
store: store,
}
if cfg.HS256Secret != "" {
a.hsKey = DeriveHS256Key(cfg.HS256Secret)
}
if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" {
a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute)
}
return a
}
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
unauthorized(w, "missing bearer token")
return
}
claims, err := a.verify(r.Context(), token, r)
if err != nil {
slog.Warn("auth failed", "err", err, "path", r.URL.Path)
unauthorized(w, "invalid token")
return
}
deviceName := sanitizeDeviceName(r.Header.Get("X-Device-Name"))
deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))
if claims.Scoped() {
// The backend authoritatively knows a scoped token is the iOS
// Shortcut, so it labels the device "shortcut" regardless of any
// header. ("ios" stays reserved for a real native client.)
deviceType = "shortcut"
}
// Register a device only when the client names itself (X-Device-Name).
// A nameless request — e.g. a polling shortcut hitting /api/clipboard/version
// without the header — must NOT be registered: the old random UA fallback
// minted a fresh "Unknown Device" row on every request and flooded the list.
if deviceName != "" {
if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{
UserID: claims.UserID,
Name: deviceName,
Type: deviceType,
LastSeen: time.Now().Unix(),
}); err != nil {
// non-fatal: log and continue so transient DB errors don't 401 users
slog.Error("device upsert failed",
"err", err, "user", claims.UserID, "device", deviceName)
}
}
ctx := context.WithValue(r.Context(), claimsCtxKey, claims)
ctx = context.WithValue(ctx, deviceCtxKey, deviceName)
ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) {
if a.cfg.AuthMode == "dev" {
return a.verifyDev(token, r)
}
if c, err := a.verifyHS256(ctx, token); err == nil {
return c, nil
}
return a.verifyRS256(ctx, token)
}
func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) {
if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
return nil, errors.New("invalid dev token")
}
userID := r.Header.Get("X-Dev-User")
if userID == "" {
userID = "dev-user"
}
return &Claims{UserID: userID, Groups: []string{"dev"}}, nil
}
func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) {
if len(a.hsKey) == 0 {
return nil, errors.New("HS256 secret not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(a.hsKey, &std, &custom); err != nil {
return nil, err
}
if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil {
return nil, err
}
claims, err := claimsFromJWT(std, custom)
if err != nil {
return nil, err
}
// HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS
// carry a jti. A validly-signed HS256 token without one must be rejected
// outright: otherwise it would fall through here as a full, unscoped account
// session with a self-declared subject — far beyond the clipboard-only
// surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's
// blast radius pinned to the shortcut scope (clipboard, and nothing else).
if std.ID == "" {
return nil, errors.New("HS256 token missing jti")
}
// The signature alone is not enough — the token must still be present and not
// revoked in the store, so a leaked or retired token can be killed
// server-side. The stored row is also the authoritative source of scopes
// (never trust scopes off the wire).
row, err := a.store.GetShortcutToken(ctx, std.ID)
if err != nil {
return nil, fmt.Errorf("shortcut token lookup: %w", err)
}
if row.Revoked != 0 {
return nil, errors.New("shortcut token revoked")
}
if row.UserID != claims.UserID {
return nil, errors.New("shortcut token subject mismatch")
}
claims.JTI = std.ID
claims.Scopes = strings.Fields(row.Scopes)
a.touchTokenAsync(std.ID)
return claims, nil
}
// touchTokenAsync records a shortcut token's last-use time off the request path
// — it's audit metadata, so a slow or failing write must never delay or fail
// the request it belongs to.
func (a *Authenticator) touchTokenAsync(jti string) {
now := time.Now().Unix()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{
LastUsedAt: &now,
Jti: jti,
}); err != nil {
slog.Warn("touch shortcut token failed", "err", err, "jti", jti)
}
}()
}
func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) {
if a.jwks == nil {
return nil, errors.New("JWKS not configured")
}
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256})
if err != nil {
return nil, err
}
if len(parsed.Headers) == 0 {
return nil, errors.New("missing token headers")
}
kid := parsed.Headers[0].KeyID
key, err := a.jwks.GetKey(ctx, kid)
if err != nil {
return nil, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.Claims(key, &std, &custom); err != nil {
return nil, err
}
expected := jwt.Expected{Time: time.Now()}
if a.cfg.OIDCIssuer != "" {
expected.Issuer = a.cfg.OIDCIssuer
}
if a.cfg.OIDCAudience != "" {
expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience)
}
if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil {
return nil, err
}
return claimsFromJWT(std, custom)
}
// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience
// set. Multiple values let one backend accept tokens minted for several OAuth
// clients (the web app and the desktop client carry different `aud`); go-jose's
// AnyAudience passes when the token's audience matches any one entry. Whitespace
// around entries is trimmed and empties dropped, so a plain single value behaves
// exactly as before.
func parseAudiences(raw string) jwt.Audience {
parts := strings.Split(raw, ",")
out := make(jwt.Audience, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) {
if std.Subject == "" {
return nil, errors.New("missing subject claim")
}
c := &Claims{UserID: std.Subject}
if g, ok := custom["groups"].([]any); ok {
for _, item := range g {
if s, ok := item.(string); ok {
c.Groups = append(c.Groups, s)
}
}
}
return c, nil
}
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(h, prefix) {
return "", false
}
tok := strings.TrimPrefix(h, prefix)
if tok == "" {
return "", false
}
return tok, true
}
func unauthorized(w http.ResponseWriter, reason string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("WWW-Authenticate", `Bearer realm="cdrop"`)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "unauthorized",
"reason": reason,
})
}
// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte
// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the
// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't
// make shortcut-token signing fail. Both signing (httpapi) and verifying use it.
func DeriveHS256Key(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// sanitizeDeviceName enforces the global ASCII-only device-name policy. Device
// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII
// reliably (and browser fetch rejects such header values outright), so the name
// is restricted to printable ASCII everywhere. Here we keep only printable ASCII
// (0x200x7E), trim, and cap the length as a server-side backstop; clients also
// validate the name up front for a clear error. Empty after sanitising → the
// caller falls back to a UA-derived default.
func sanitizeDeviceName(raw string) string {
var b strings.Builder
for _, r := range raw {
if r >= 0x20 && r <= 0x7E {
b.WriteRune(r)
}
}
name := strings.TrimSpace(b.String())
if len(name) > 64 {
name = strings.TrimSpace(name[:64])
}
return name
}
// normalizeDeviceType whitelists the client-declared X-Device-Type so a device
// row only ever carries a known kind; anything unrecognised (incl. empty) falls
// back to "browser", the default web client. Desktop clients send macos/windows;
// "ios" is reserved for a future native iOS client (the Shortcut never sends it).
func normalizeDeviceType(raw string) string {
switch t := strings.ToLower(strings.TrimSpace(raw)); t {
case "macos", "windows", "linux", "ios", "browser":
return t
default:
return "browser"
}
}
+490
View File
@@ -0,0 +1,490 @@
package jwtauth
import (
"context"
"crypto/rand"
"crypto/rsa"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
)
// fakeDeviceUpserter records UpsertDevice calls without touching SQL and serves
// shortcut-token lookups from an in-memory map (empty → "not found", so plain
// device-only tests are unaffected).
type fakeDeviceUpserter struct {
calls []db.UpsertDeviceParams
err error
tokens map[string]db.ShortcutToken
}
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
f.calls = append(f.calls, arg)
return f.err
}
func (f *fakeDeviceUpserter) GetShortcutToken(_ context.Context, jti string) (db.ShortcutToken, error) {
if t, ok := f.tokens[jti]; ok {
return t, nil
}
return db.ShortcutToken{}, sql.ErrNoRows
}
// TouchShortcutTokenUsed is a no-op in tests: it runs on a detached goroutine
// (touchTokenAsync), so recording into the fake here would race the test body.
func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.TouchShortcutTokenUsedParams) error {
return nil
}
// echo handler that responds with the claims+device discovered in context.
func echoMe(w http.ResponseWriter, r *http.Request) {
claims, _ := ClaimsFromContext(r.Context())
dev, _ := DeviceNameFromContext(r.Context())
resp := map[string]any{
"user_id": claims.UserID,
"groups": claims.Groups,
"device": dev,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func TestDevMode_AcceptsTokenAndUsesXDevUser(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "secret-token"}
dev := &fakeDeviceUpserter{}
a := New(cfg, dev)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer secret-token")
req.Header.Set("X-Dev-User", "alice")
req.Header.Set("X-Device-Name", "tab-1")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got["user_id"] != "alice" {
t.Errorf("user_id: got %v, want alice", got["user_id"])
}
if got["device"] != "tab-1" {
t.Errorf("device: got %v, want tab-1", got["device"])
}
if len(dev.calls) != 1 {
t.Errorf("UpsertDevice calls: got %d, want 1", len(dev.calls))
}
}
func TestDevMode_DefaultsUserIDWhenHeaderMissing(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "t"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer t")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rr.Code)
}
var got map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &got)
if got["user_id"] != "dev-user" {
t.Errorf("user_id: got %v, want dev-user", got["user_id"])
}
}
func TestDevMode_RejectsWrongToken(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "right"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer wrong")
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401", rr.Code)
}
}
func TestDevMode_RejectsMissingHeader(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "x"}
a := New(cfg, &fakeDeviceUpserter{})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401", rr.Code)
}
}
// --- prod RS256 path ---
func newRSAKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa keygen: %v", err)
}
return k
}
func startJWKSServer(t *testing.T, kid string, pub *rsa.PublicKey) *httptest.Server {
t.Helper()
jwk := jose.JSONWebKey{Key: pub, KeyID: kid, Algorithm: "RS256", Use: "sig"}
set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}}
body, err := json.Marshal(set)
if err != nil {
t.Fatalf("marshal jwks: %v", err)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
}
func signRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.Claims, custom map[string]any) string {
t.Helper()
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: priv},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid),
)
if err != nil {
t.Fatalf("new signer: %v", err)
}
tok, err := jwt.Signed(signer).Claims(claims).Claims(custom).Serialize()
if err != nil {
t.Fatalf("sign: %v", err)
}
return tok
}
func TestProdMode_AcceptsValidRS256(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, map[string]any{"groups": []any{"users"}})
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &got)
if got["user_id"] != "user-bob" {
t.Errorf("user_id: got %v, want user-bob", got["user_id"])
}
}
func TestProdMode_RejectsExpiredRS256(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
}
a := New(cfg, &fakeDeviceUpserter{})
past := time.Now().Add(-time.Hour)
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(past),
Expiry: jwt.NewNumericDate(past.Add(time.Minute)), // already 59min stale
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
}
}
func TestProdMode_RejectsWrongIssuer(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop",
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://attacker.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
}
}
// Multi-audience (R1): one backend serving web + desktop clients. A desktop
// token carries aud=<desktop client_id>; it must pass when OIDCAudience lists
// both client_ids comma-separated, and still be rejected when its aud is in
// neither.
func TestProdMode_AcceptsSecondAudienceInList(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop-web , cdrop-desktop", // spaces trimmed
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"cdrop-desktop"}, // the desktop client's aud
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
}
}
func TestProdMode_RejectsAudienceNotInList(t *testing.T) {
priv := newRSAKey(t)
kid := "key-1"
srv := startJWKSServer(t, kid, &priv.PublicKey)
defer srv.Close()
cfg := &config.Config{
AuthMode: "prod",
OIDCJWKSURL: srv.URL,
OIDCIssuer: "https://oauth.example/",
OIDCAudience: "cdrop-web,cdrop-desktop",
}
a := New(cfg, &fakeDeviceUpserter{})
now := time.Now()
std := jwt.Claims{
Issuer: "https://oauth.example/",
Subject: "user-bob",
Audience: jwt.Audience{"some-other-client"},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
}
tok := signRS256(t, priv, kid, std, nil)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req.Header.Set("Authorization", "Bearer "+tok)
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String())
}
}
// A nameless request (no X-Device-Name) must NOT register a device — preventing
// the random-fallback "Unknown Device" flood from polling clients.
func TestNamelessRequestSkipsDeviceUpsert(t *testing.T) {
cfg := &config.Config{AuthMode: "dev", DevToken: "t"}
store := &fakeDeviceUpserter{}
a := New(cfg, store)
req := httptest.NewRequest(http.MethodGet, "/api/clipboard/version", nil)
req.Header.Set("Authorization", "Bearer t") // no X-Device-Name
rr := httptest.NewRecorder()
a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rr.Code)
}
if len(store.calls) != 0 {
t.Errorf("nameless request must not upsert a device, got %d calls", len(store.calls))
}
}
func signHS256(t *testing.T, secret, sub, jti, scope string, exp time.Time) string {
t.Helper()
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: DeriveHS256Key(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
t.Fatalf("signer: %v", err)
}
std := jwt.Claims{
Subject: sub,
ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp),
}
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return tok
}
func serveBearer(a *Authenticator, tok string) (int, *Claims) {
req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil)
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("X-Device-Name", "iPhone")
rr := httptest.NewRecorder()
var got *Claims
a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, _ = ClaimsFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rr, req)
return rr.Code, got
}
// A valid HS256 shortcut token authenticates and carries its jti + stored scope;
// revocation, an unknown jti, and a subject/owner mismatch are all rejected even
// though the signature is valid — the store is the authority.
func TestShortcutToken_HS256VerifyRevokeAndScope(t *testing.T) {
secret := "test-hs256-secret-at-least-32-bytes-long" // HS256 needs >= 32 bytes
cfg := &config.Config{AuthMode: "prod", HS256Secret: secret}
exp := time.Now().Add(time.Hour)
store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{
"jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()},
}}
a := New(cfg, store)
tok := signHS256(t, secret, "user-x", "jti-1", "clipboard", exp)
code, claims := serveBearer(a, tok)
if code != http.StatusOK {
t.Fatalf("valid token: got %d, want 200", code)
}
if claims == nil || !claims.Scoped() || claims.JTI != "jti-1" || !claims.HasScope("clipboard") {
t.Fatalf("claims not populated as scoped clipboard token: %+v", claims)
}
// The device registers under the (ASCII) X-Device-Name the request carried.
if len(store.calls) == 0 || store.calls[len(store.calls)-1].Name != "iPhone" {
t.Errorf("expected device name from header, got %+v", store.calls)
}
// Subject mismatch: stored row owned by a different user than the token's sub.
store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "someone-else", Scopes: "clipboard", ExpiresAt: exp.Unix()}
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("subject mismatch: got %d, want 401", code)
}
// Revoked row → reject.
store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", Revoked: 1, ExpiresAt: exp.Unix()}
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("revoked token: got %d, want 401", code)
}
// Unknown jti (signed but no stored row) → reject.
ghost := signHS256(t, secret, "user-x", "ghost", "clipboard", exp)
if code, _ := serveBearer(a, ghost); code != http.StatusUnauthorized {
t.Errorf("unknown jti: got %d, want 401", code)
}
}
// An HS256 token WITHOUT a jti must be rejected outright (G1): the HS256 path
// only ever signs scoped shortcut tokens, which always carry a jti. A jti-less
// but validly-signed token would otherwise fall through as a full, unscoped
// account session with a self-declared subject — so a leaked HS256 secret could
// mint arbitrary-subject sessions far beyond the clipboard scope. Requiring the
// jti pins a leaked secret's blast radius to clipboard-only.
func TestShortcutToken_HS256RejectsMissingJTI(t *testing.T) {
secret := "test-hs256-secret-at-least-32-bytes-long"
cfg := &config.Config{AuthMode: "prod", HS256Secret: secret}
a := New(cfg, &fakeDeviceUpserter{})
tok := signHS256(t, secret, "user-x", "", "clipboard", time.Now().Add(time.Hour))
if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized {
t.Errorf("jti-less HS256 token must be rejected, got %d", code)
}
}
func TestSanitizeDeviceName(t *testing.T) {
cases := []struct{ in, want string }{
{"iPhone", "iPhone"},
{" My iPad ", "My iPad"},
{"我的iPhone", "iPhone"}, // CJK stripped
{"我的电脑", ""}, // all non-ASCII → empty (caller falls back)
{"Mac Book", "MacBook"}, // NBSP dropped
}
for _, c := range cases {
if got := sanitizeDeviceName(c.in); got != c.want {
t.Errorf("sanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
package relay
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"time"
)
const (
DefaultMaxSessions = 8
DefaultMaxSessionsPerUser = 3
DefaultSessionCap = 64 << 20 // 64 MiB
DefaultChunkLimit = 1 << 20 // 1 MiB
// DefaultIdleTTL is how long a registered relay session may sit without any
// chunk/stream activity before the reaper tears it down. A fallback that
// registered but never got used (both peers vanished) is reclaimed rather
// than pinning a slot — and, once bytes flowed, its 64 MiB ring — forever.
DefaultIdleTTL = 2 * time.Minute
// DefaultReapPeriod is how often the reaper goroutine sweeps for idle sessions.
DefaultReapPeriod = 30 * time.Second
)
var (
ErrTooManySessions = errors.New("relay: too many active sessions")
ErrForbidden = errors.New("relay: caller is not a participant of this session")
ErrNotRegistered = errors.New("relay: session not registered")
)
// Session pairs a Ring with the metadata that proves who is allowed to use it.
// The ring is allocated lazily on the first Acquire so a registered-but-unused
// fallback costs only bookkeeping until bytes actually flow.
type Session struct {
ID string
UserID string
Sender string // device name of the legitimate sender (POSTs chunks)
Receiver string // device name of the legitimate receiver (GETs the stream)
capBytes int
ring *Ring // nil until first Acquire; guarded by Manager.mu
bytesWritten atomic.Int64
lastActivity atomic.Int64 // unix seconds; the reaper's idle clock
readerActive atomic.Bool // single-consumer guard (a second reader tears the ring)
}
func (s *Session) touch() { s.lastActivity.Store(time.Now().Unix()) }
func (s *Session) Write(ctx context.Context, p []byte) (int, error) {
s.touch()
n, err := s.ring.Write(ctx, p)
if n > 0 {
s.bytesWritten.Add(int64(n))
}
return n, err
}
func (s *Session) Read(ctx context.Context, p []byte) (int, error) {
s.touch()
return s.ring.Read(ctx, p)
}
func (s *Session) CloseWriter() {
if s.ring != nil {
s.ring.CloseWriter()
}
}
func (s *Session) Abort() {
if s.ring != nil {
s.ring.Abort()
}
}
func (s *Session) BytesWritten() int64 { return s.bytesWritten.Load() }
// AcquireReader claims the single consumer slot. The relay stream is strictly
// single-consumer: two concurrent readers would each drain bytes out of the
// ring and silently corrupt the receiver's copy (G2). A second reader gets a
// false here so the handler can 409 instead of joining.
func (s *Session) AcquireReader() bool { return s.readerActive.CompareAndSwap(false, true) }
// ReleaseReader frees the consumer slot when the stream handler returns.
func (s *Session) ReleaseReader() { s.readerActive.Store(false) }
// Manager owns active relay sessions. A session must be Register-ed (which the
// transfer service does when a transfer enters RELAY_ACTIVE) before either peer
// can Acquire it. This is the core of the R1/G2 hardening: the old Acquire
// minted a session for any unknown id on demand, so 8 one-byte requests could
// exhaust every slot and pin ~512 MiB. Now only legitimate, server-minted
// transfers that actually reached RELAY_ACTIVE get a slot, capped both globally
// and per-user, and reclaimed when idle.
type Manager struct {
mu sync.Mutex
sessions map[string]*Session
perUser map[string]int
maxSessions int
maxPerUser int
sessionCap int
idleTTL time.Duration
}
func NewManager(maxSessions, sessionCapBytes int) *Manager {
if maxSessions <= 0 {
maxSessions = DefaultMaxSessions
}
if sessionCapBytes <= 0 {
sessionCapBytes = DefaultSessionCap
}
maxPerUser := DefaultMaxSessionsPerUser
if maxPerUser > maxSessions {
// A per-user cap above the global cap is meaningless; clamp so small
// global caps (tests, constrained deploys) still behave sanely.
maxPerUser = maxSessions
}
return &Manager{
sessions: map[string]*Session{},
perUser: map[string]int{},
maxSessions: maxSessions,
maxPerUser: maxPerUser,
sessionCap: sessionCapBytes,
idleTTL: DefaultIdleTTL,
}
}
// Register reserves a relay slot for a transfer that has just entered
// RELAY_ACTIVE, recording its two legitimate participants. Only a registered
// session can later be joined via Acquire.
//
// Idempotent for the same (id, userID): a duplicate Register just refreshes the
// idle clock. A mismatched userID is ErrForbidden (ids are server-minted, so
// this is defensive). Returns ErrTooManySessions when the global or per-user
// cap is reached — the caller (transfer service) surfaces that as a retryable
// failure rather than moving the transfer into a relay mode with no slot.
func (m *Manager) Register(id, userID, sender, receiver string) error {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[id]; ok {
if s.UserID != userID {
return ErrForbidden
}
s.touch()
return nil
}
if len(m.sessions) >= m.maxSessions {
return ErrTooManySessions
}
if m.perUser[userID] >= m.maxPerUser {
return ErrTooManySessions
}
s := &Session{
ID: id,
UserID: userID,
Sender: sender,
Receiver: receiver,
capBytes: m.sessionCap,
}
s.touch()
m.sessions[id] = s
m.perUser[userID] = m.perUser[userID] + 1
return nil
}
// Acquire joins an already-registered relay session as one of its two
// participants. Unlike the old implementation it never creates a session:
// - unknown id → ErrNotRegistered
// - different user → ErrForbidden
// - not sender/receiver → ErrForbidden
//
// device is the caller's X-Device-Name (set by the auth middleware). The 64 MiB
// ring is allocated here, lazily, on the first join.
func (m *Manager) Acquire(id, userID, device string) (*Session, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[id]
if !ok {
return nil, ErrNotRegistered
}
if s.UserID != userID {
return nil, ErrForbidden
}
if device == "" || (device != s.Sender && device != s.Receiver) {
return nil, ErrForbidden
}
if s.ring == nil {
s.ring = newRing(s.capBytes)
}
s.touch()
return s, nil
}
// Release removes the session from the active set and aborts any blocked
// Read/Write. Safe to call multiple times (terminal transitions and the pending
// sweeper both call it). A no-op for an id that was never registered.
func (m *Manager) Release(id string) {
m.mu.Lock()
s, ok := m.sessions[id]
if ok {
delete(m.sessions, id)
m.decUserLocked(s.UserID)
}
m.mu.Unlock()
if ok {
s.Abort()
}
}
// decUserLocked drops one from a user's active count, deleting the key at zero
// so perUser never accumulates empty entries. Caller holds m.mu.
func (m *Manager) decUserLocked(userID string) {
if n := m.perUser[userID]; n <= 1 {
delete(m.perUser, userID)
} else {
m.perUser[userID] = n - 1
}
}
// Active reports the current count of held sessions.
func (m *Manager) Active() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.sessions)
}
// reapIdle aborts and removes every session whose last activity is older than
// the idle TTL, returning how many it reclaimed. now is unix seconds (injected
// so tests don't depend on the wall clock).
func (m *Manager) reapIdle(now int64) int {
cutoff := now - int64(m.idleTTL/time.Second)
var dead []*Session
m.mu.Lock()
for id, s := range m.sessions {
if s.lastActivity.Load() < cutoff {
delete(m.sessions, id)
m.decUserLocked(s.UserID)
dead = append(dead, s)
}
}
m.mu.Unlock()
for _, s := range dead {
s.Abort()
}
return len(dead)
}
// RunReaper blocks until ctx is cancelled, periodically reclaiming idle relay
// sessions (fallbacks that registered but never completed — e.g. both peers
// went away mid-transfer). Mirrors transfer.RunSweeper; start it once at boot.
func (m *Manager) RunReaper(ctx context.Context) {
t := time.NewTicker(DefaultReapPeriod)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n := m.reapIdle(time.Now().Unix()); n > 0 {
slog.Info("relay reaper: reclaimed idle sessions", "count", n)
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
package relay
import (
"errors"
"testing"
"time"
)
// register is a test helper: every session must be Register-ed before it can be
// Acquire-d. Sender "s" / receiver "r" unless a test needs specific names.
func register(t *testing.T, m *Manager, id, user string) {
t.Helper()
if err := m.Register(id, user, "s", "r"); err != nil {
t.Fatalf("register %s/%s: %v", id, user, err)
}
}
func TestManager_AcquireReturnsSameSession(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "s1", "alice")
s1, err := m.Acquire("s1", "alice", "s")
if err != nil {
t.Fatalf("acquire 1: %v", err)
}
s2, err := m.Acquire("s1", "alice", "r")
if err != nil {
t.Fatalf("acquire 2: %v", err)
}
if s1 != s2 {
t.Error("expected same session pointer on repeat Acquire")
}
}
// Acquire never mints a session: an id that was never Register-ed is rejected.
// This is the core R1 fix — the old Acquire created one on demand, so any
// authenticated client could conjure sessions for arbitrary ids and exhaust
// every slot.
func TestManager_AcquireUnregisteredRejected(t *testing.T) {
m := NewManager(8, 1024)
if _, err := m.Acquire("ghost", "alice", "s"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("expected ErrNotRegistered, got %v", err)
}
if got := m.Active(); got != 0 {
t.Errorf("a rejected Acquire must not create a session; Active=%d", got)
}
}
func TestManager_DifferentUserForbidden(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "s1", "alice")
if _, err := m.Acquire("s1", "mallory", "s"); !errors.Is(err, ErrForbidden) {
t.Errorf("expected ErrForbidden, got %v", err)
}
}
// Only the registered sender/receiver device may join (G2). A third device of
// the same account — or a missing device name — is forbidden.
func TestManager_NonParticipantForbidden(t *testing.T) {
m := NewManager(8, 1024)
if err := m.Register("s1", "alice", "laptop", "phone"); err != nil {
t.Fatalf("register: %v", err)
}
if _, err := m.Acquire("s1", "alice", "tablet"); !errors.Is(err, ErrForbidden) {
t.Errorf("non-participant device: expected ErrForbidden, got %v", err)
}
if _, err := m.Acquire("s1", "alice", ""); !errors.Is(err, ErrForbidden) {
t.Errorf("empty device: expected ErrForbidden, got %v", err)
}
if _, err := m.Acquire("s1", "alice", "laptop"); err != nil {
t.Errorf("sender device must be allowed: %v", err)
}
if _, err := m.Acquire("s1", "alice", "phone"); err != nil {
t.Errorf("receiver device must be allowed: %v", err)
}
}
// Register, not Acquire, now enforces the global cap.
func TestManager_GlobalCapEnforced(t *testing.T) {
m := NewManager(2, 1024)
if err := m.Register("a", "u1", "s", "r"); err != nil {
t.Fatalf("a: %v", err)
}
if err := m.Register("b", "u2", "s", "r"); err != nil {
t.Fatalf("b: %v", err)
}
if err := m.Register("c", "u3", "s", "r"); !errors.Is(err, ErrTooManySessions) {
t.Errorf("expected ErrTooManySessions, got %v", err)
}
if got := m.Active(); got != 2 {
t.Errorf("Active: got %d, want 2", got)
}
}
// A single user can't monopolise every global slot — the per-user cap stops
// them well before the global cap, leaving room for other users.
func TestManager_PerUserCapEnforced(t *testing.T) {
m := NewManager(8, 1024) // per-user default is 3
for i, id := range []string{"a", "b", "c"} {
if err := m.Register(id, "greedy", "s", "r"); err != nil {
t.Fatalf("register %d: %v", i, err)
}
}
if err := m.Register("d", "greedy", "s", "r"); !errors.Is(err, ErrTooManySessions) {
t.Errorf("4th session for one user: expected ErrTooManySessions, got %v", err)
}
// A different user is unaffected — global slots remain.
if err := m.Register("e", "other", "s", "r"); err != nil {
t.Errorf("other user should still register: %v", err)
}
}
// Register is idempotent for the same owner (e.g. a retried /fallback) and does
// not consume a second slot.
func TestManager_RegisterIdempotent(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "s1", "alice")
register(t, m, "s1", "alice")
if got := m.Active(); got != 1 {
t.Errorf("duplicate Register must not add a slot; Active=%d", got)
}
if err := m.Register("s1", "mallory", "s", "r"); !errors.Is(err, ErrForbidden) {
t.Errorf("re-register under another user: expected ErrForbidden, got %v", err)
}
}
func TestManager_ReleaseFreesSlot(t *testing.T) {
m := NewManager(1, 1024)
register(t, m, "a", "u")
m.Release("a")
if got := m.Active(); got != 0 {
t.Errorf("Active after release: got %d, want 0", got)
}
// A new session can now slot in (both global and per-user counts dropped).
if err := m.Register("b", "u", "s", "r"); err != nil {
t.Errorf("register after release: %v", err)
}
}
func TestManager_ReleaseAbortsSession(t *testing.T) {
m := NewManager(1, 1024)
register(t, m, "a", "u")
s, err := m.Acquire("a", "u", "s") // allocates the ring
if err != nil {
t.Fatalf("acquire: %v", err)
}
m.Release("a")
if _, err := s.Write(nil, []byte("x")); !errors.Is(err, ErrClosed) {
t.Errorf("write after release: got %v, want ErrClosed", err)
}
}
// The reaper reclaims a session whose last activity is older than the idle TTL,
// dropping both the global and per-user counts so the slot is reusable.
func TestManager_ReaperReclaimsIdle(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "a", "u")
// Just-registered: a sweep "now" leaves it alone.
if n := m.reapIdle(time.Now().Unix()); n != 0 {
t.Errorf("fresh session reclaimed: got %d, want 0", n)
}
// Sweep far enough in the future that it's past the idle TTL.
future := time.Now().Add(DefaultIdleTTL + time.Minute).Unix()
if n := m.reapIdle(future); n != 1 {
t.Errorf("idle session not reclaimed: got %d, want 1", n)
}
if got := m.Active(); got != 0 {
t.Errorf("Active after reap: got %d, want 0", got)
}
// The per-user slot was freed too.
if err := m.Register("b", "u", "s", "r"); err != nil {
t.Errorf("register after reap: %v", err)
}
}
// The stream is single-consumer: a second concurrent reader is refused so it
// can't drain bytes out from under the first and corrupt the receiver's copy.
func TestSession_SingleReader(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "a", "u")
s, err := m.Acquire("a", "u", "r")
if err != nil {
t.Fatalf("acquire: %v", err)
}
if !s.AcquireReader() {
t.Fatal("first AcquireReader should succeed")
}
if s.AcquireReader() {
t.Error("second AcquireReader must fail while the first holds it")
}
s.ReleaseReader()
if !s.AcquireReader() {
t.Error("AcquireReader should succeed again after release")
}
}
+170
View File
@@ -0,0 +1,170 @@
package relay
import (
"context"
"errors"
"io"
"sync"
)
// ErrClosed is returned by Read/Write after Abort.
var ErrClosed = errors.New("relay: ring aborted")
// Ring is a bounded byte buffer with single-producer / single-consumer
// blocking semantics. Plan §M5: 单 session 64 MiB.
//
// Internally it's a flat slice with head/tail wrap-around, guarded by a
// sync.Cond. Both Read and Write accept ctx so handlers can abort fast on
// client disconnect — the watchdog goroutine broadcasts the cond when ctx
// fires so blocked Wait calls re-check ctx.Err().
type Ring struct {
mu sync.Mutex
cond *sync.Cond
buf []byte
head int
tail int
used int
cap int
closed bool // sender called CloseWriter; reader drains to EOF
aborted bool // both sides torn down
}
func newRing(capacity int) *Ring {
r := &Ring{
buf: make([]byte, capacity),
cap: capacity,
}
r.cond = sync.NewCond(&r.mu)
return r
}
func (r *Ring) Write(ctx context.Context, p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
stop := r.watchCtx(ctx)
defer stop()
r.mu.Lock()
defer r.mu.Unlock()
written := 0
for written < len(p) {
for r.used == r.cap && !r.aborted && !r.closed && ctxOK(ctx) {
r.cond.Wait()
}
if !ctxOK(ctx) {
return written, ctx.Err()
}
if r.aborted {
return written, ErrClosed
}
if r.closed {
return written, ErrClosed
}
free := r.cap - r.used
toWrite := len(p) - written
if toWrite > free {
toWrite = free
}
end := r.tail + toWrite
if end <= r.cap {
copy(r.buf[r.tail:end], p[written:written+toWrite])
} else {
first := r.cap - r.tail
copy(r.buf[r.tail:], p[written:written+first])
copy(r.buf[:toWrite-first], p[written+first:written+toWrite])
}
r.tail = (r.tail + toWrite) % r.cap
r.used += toWrite
written += toWrite
r.cond.Broadcast()
}
return written, nil
}
func (r *Ring) Read(ctx context.Context, p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
stop := r.watchCtx(ctx)
defer stop()
r.mu.Lock()
defer r.mu.Unlock()
for r.used == 0 && !r.closed && !r.aborted && ctxOK(ctx) {
r.cond.Wait()
}
if !ctxOK(ctx) {
return 0, ctx.Err()
}
if r.used == 0 {
if r.aborted {
return 0, ErrClosed
}
return 0, io.EOF
}
n := r.used
if n > len(p) {
n = len(p)
}
end := r.head + n
if end <= r.cap {
copy(p[:n], r.buf[r.head:end])
} else {
first := r.cap - r.head
copy(p[:first], r.buf[r.head:])
copy(p[first:n], r.buf[:n-first])
}
r.head = (r.head + n) % r.cap
r.used -= n
r.cond.Broadcast()
return n, nil
}
// CloseWriter signals end-of-stream from the sender side. Reader returns
// io.EOF after draining whatever is still buffered.
func (r *Ring) CloseWriter() {
r.mu.Lock()
r.closed = true
r.cond.Broadcast()
r.mu.Unlock()
}
// Abort tears the ring down hard. Subsequent Read/Write return ErrClosed.
func (r *Ring) Abort() {
r.mu.Lock()
r.aborted = true
r.cond.Broadcast()
r.mu.Unlock()
}
func (r *Ring) watchCtx(ctx context.Context) func() {
if ctx == nil || ctx.Err() != nil {
return func() {}
}
stop := make(chan struct{})
go func() {
select {
case <-ctx.Done():
r.mu.Lock()
r.cond.Broadcast()
r.mu.Unlock()
case <-stop:
}
}()
return func() { close(stop) }
}
func ctxOK(ctx context.Context) bool {
if ctx == nil {
return true
}
return ctx.Err() == nil
}
+177
View File
@@ -0,0 +1,177 @@
package relay
import (
"bytes"
"context"
"errors"
"io"
"sync"
"testing"
"time"
)
func TestRing_BasicWriteRead(t *testing.T) {
r := newRing(64)
want := []byte("hello, world")
if n, err := r.Write(context.Background(), want); err != nil || n != len(want) {
t.Fatalf("write: n=%d err=%v", n, err)
}
got := make([]byte, 32)
n, err := r.Read(context.Background(), got)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got[:n], want) {
t.Errorf("read mismatch: got %q want %q", got[:n], want)
}
}
func TestRing_CloseWriterSignalsEOF(t *testing.T) {
r := newRing(32)
_, _ = r.Write(context.Background(), []byte("abc"))
r.CloseWriter()
got := make([]byte, 16)
n, err := r.Read(context.Background(), got)
if err != nil || n != 3 {
t.Fatalf("first read: n=%d err=%v", n, err)
}
n, err = r.Read(context.Background(), got)
if !errors.Is(err, io.EOF) {
t.Errorf("expected EOF after drain; got n=%d err=%v", n, err)
}
}
func TestRing_AbortReturnsErrClosed(t *testing.T) {
r := newRing(32)
r.Abort()
if _, err := r.Write(context.Background(), []byte("x")); !errors.Is(err, ErrClosed) {
t.Errorf("write after abort: got %v want ErrClosed", err)
}
if _, err := r.Read(context.Background(), make([]byte, 1)); !errors.Is(err, ErrClosed) {
t.Errorf("read after abort: got %v want ErrClosed", err)
}
}
func TestRing_BlocksWhenFullThenUnblocksOnRead(t *testing.T) {
r := newRing(8)
if _, err := r.Write(context.Background(), []byte("12345678")); err != nil {
t.Fatalf("fill: %v", err)
}
done := make(chan struct{})
go func() {
_, err := r.Write(context.Background(), []byte("X"))
if err != nil {
t.Errorf("blocked write: %v", err)
}
close(done)
}()
// Without a reader the goroutine should block.
select {
case <-done:
t.Fatal("write should block when ring is full")
case <-time.After(50 * time.Millisecond):
}
got := make([]byte, 4)
if _, err := r.Read(context.Background(), got); err != nil {
t.Fatalf("read: %v", err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("write did not unblock after read")
}
}
func TestRing_CtxCancelsBlockedRead(t *testing.T) {
r := newRing(8)
ctx, cancel := context.WithCancel(context.Background())
type res struct {
n int
err error
}
out := make(chan res, 1)
go func() {
buf := make([]byte, 4)
n, err := r.Read(ctx, buf)
out <- res{n, err}
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case r := <-out:
if !errors.Is(r.err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", r.err)
}
case <-time.After(time.Second):
t.Fatal("blocked read did not unblock on ctx cancel")
}
}
func TestRing_WrapsAround(t *testing.T) {
r := newRing(4)
// Cycle through enough bytes to force head/tail wrapping.
for i := 0; i < 20; i++ {
_, _ = r.Write(context.Background(), []byte{byte(i)})
got := make([]byte, 1)
n, _ := r.Read(context.Background(), got)
if n != 1 || got[0] != byte(i) {
t.Errorf("iter %d: got %v want [%d]", i, got, i)
}
}
}
func TestRing_StressManyChunks(t *testing.T) {
const total = 4 * 1024
r := newRing(64)
var wgWriter, wgReader sync.WaitGroup
wgWriter.Add(1)
wgReader.Add(1)
src := make([]byte, total)
for i := range src {
src[i] = byte(i)
}
go func() {
defer wgWriter.Done()
for i := 0; i < total; i += 13 {
end := i + 13
if end > total {
end = total
}
_, _ = r.Write(context.Background(), src[i:end])
}
r.CloseWriter()
}()
got := make([]byte, 0, total)
go func() {
defer wgReader.Done()
buf := make([]byte, 17)
for {
n, err := r.Read(context.Background(), buf)
got = append(got, buf[:n]...)
if errors.Is(err, io.EOF) {
return
}
if err != nil {
t.Errorf("read: %v", err)
return
}
}
}()
wgWriter.Wait()
wgReader.Wait()
if !bytes.Equal(got, src) {
t.Errorf("mismatch: got %d bytes, want %d", len(got), len(src))
}
}
+266
View File
@@ -0,0 +1,266 @@
package transfer
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
)
var (
ErrNotFound = errors.New("transfer: session not found")
ErrForbidden = errors.New("transfer: not your session")
ErrInvalidTransition = errors.New("transfer: invalid state transition")
ErrRelayUnavailable = errors.New("transfer: relay unavailable")
)
// SessionView is the session shape pushed to clients (omits internal columns).
type SessionView struct {
ID string `json:"id"`
SenderName string `json:"sender_name"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
FileSHA256 string `json:"file_sha256,omitempty"`
}
// RelayController is the subset of *relay.Manager the transfer service drives:
// it reserves a relay slot when a transfer enters RELAY_ACTIVE (Register) and
// frees it when the transfer reaches a terminal state (Release). Declared as an
// interface so the transfer package doesn't gain a hard import of relay (avoids
// cycles in tests).
type RelayController interface {
Register(id, userID, sender, receiver string) error
Release(sessionID string)
}
type nopRelay struct{}
func (nopRelay) Register(_, _, _, _ string) error { return nil }
func (nopRelay) Release(string) {}
type Service struct {
queries *db.Queries
hub *hub.Hub
relay RelayController
now func() time.Time
newID func() string
}
func NewService(queries *db.Queries, h *hub.Hub, r RelayController) *Service {
if r == nil {
r = nopRelay{}
}
return &Service{
queries: queries,
hub: h,
relay: r,
now: time.Now,
newID: defaultNewID,
}
}
func defaultNewID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand only fails if the OS RNG is broken; panicking is correct.
panic(fmt.Sprintf("crypto/rand: %v", err))
}
return hex.EncodeToString(b[:])
}
// InitParams are the inputs to Init.
type InitParams struct {
UserID string
SenderName string
ReceiverName string
FileName string
FileSize int64
FileSHA256 string
}
// Init creates a new PENDING session and pushes transfer:incoming to the receiver.
// Returns the new session ID. Receiver offline does not fail Init — the receiver
// will see the session next time it connects via /api/devices or a fresh init.
func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
if p.SenderName == "" || p.ReceiverName == "" || p.FileName == "" {
return "", errors.New("init: sender_name, receiver_name, file_name required")
}
if p.SenderName == p.ReceiverName {
return "", errors.New("init: sender and receiver must differ")
}
// brief §2 explicitly says "不限文件大小"; 0-byte is legal (empty file
// transfer still needs control frames). Negatives are nonsense.
if p.FileSize < 0 {
return "", errors.New("init: file_size cannot be negative")
}
id := s.newID()
createdAt := s.now().Unix()
var sha *string
if p.FileSHA256 != "" {
v := p.FileSHA256
sha = &v
}
if err := s.queries.CreateTransferSession(ctx, db.CreateTransferSessionParams{
ID: id,
UserID: p.UserID,
SenderName: p.SenderName,
ReceiverName: p.ReceiverName,
FileName: p.FileName,
FileSize: p.FileSize,
FileSha256: sha,
CreatedAt: createdAt,
}); err != nil {
return "", fmt.Errorf("create session: %w", err)
}
view := SessionView{
ID: id,
SenderName: p.SenderName,
FileName: p.FileName,
FileSize: p.FileSize,
FileSHA256: p.FileSHA256,
}
autoAccept := p.FileSize <= AutoAcceptThreshold
s.hub.SendTo(p.UserID, p.ReceiverName, hub.Event{
Type: "transfer:incoming",
Data: map[string]any{
"session": view,
"auto_accept": autoAccept,
},
})
return id, nil
}
// Transition validates and applies a state change, then broadcasts transfer:state
// to both sender and receiver. When entering RELAY_ACTIVE, additionally emits
// transfer:relay_ready with the chunk/stream URLs (brief §5). When entering a
// terminal state, releases the relay session if any.
func (s *Service) Transition(
ctx context.Context,
userID, sessionID, target string,
mode, reason string,
bytes int64,
) error {
sess, err := s.queries.GetTransferSession(ctx, sessionID)
if err != nil {
return ErrNotFound
}
if sess.UserID != userID {
return ErrForbidden
}
if !IsValidTransition(sess.State, target) {
return fmt.Errorf("%w: %s→%s", ErrInvalidTransition, sess.State, target)
}
// Reserve the relay slot BEFORE committing the state change: if the relay is
// full (global or per-user cap) the transfer must not advance into a relay
// mode it has no slot for — surface a retryable error instead. Registering
// here also records the two legitimate participants, which is what later lets
// the relay endpoints reject anyone who isn't the sender or receiver (R1/G2).
if target == StateRelayActive {
if err := s.relay.Register(sessionID, userID, sess.SenderName, sess.ReceiverName); err != nil {
return fmt.Errorf("%w: %v", ErrRelayUnavailable, err)
}
}
args := db.UpdateTransferStateParams{
State: target,
ID: sessionID,
}
if mode != "" {
v := mode
args.Mode = &v
}
if reason != "" {
v := reason
args.FailReason = &v
}
if IsTerminal(target) {
t := s.now().Unix()
args.FinishedAt = &t
}
if bytes > 0 {
v := bytes
args.BytesTransferred = &v
}
if _, err := s.queries.UpdateTransferState(ctx, args); err != nil {
return fmt.Errorf("update state: %w", err)
}
stateEv := hub.Event{
Type: "transfer:state",
Data: stateEventPayload(sessionID, target, mode, reason),
}
s.hub.SendTo(userID, sess.SenderName, stateEv)
s.hub.SendTo(userID, sess.ReceiverName, stateEv)
if target == StateRelayActive {
readyEv := hub.Event{
Type: "transfer:relay_ready",
Data: map[string]any{
"session_id": sessionID,
"sender_url": fmt.Sprintf("/api/relay/%s/chunk", sessionID),
"receiver_url": fmt.Sprintf("/api/relay/%s/stream", sessionID),
},
}
s.hub.SendTo(userID, sess.SenderName, readyEv)
s.hub.SendTo(userID, sess.ReceiverName, readyEv)
}
if IsTerminal(target) {
s.relay.Release(sessionID)
}
return nil
}
func stateEventPayload(sessionID, state, mode, reason string) map[string]any {
p := map[string]any{
"session_id": sessionID,
"state": state,
}
if mode != "" {
p["mode"] = mode
}
if reason != "" {
p["reason"] = reason
}
return p
}
// ExpirePending cancels any PENDING sessions older than now-ttl and pushes
// transfer:state to both peers. Returns the number of expired sessions.
func (s *Service) ExpirePending(ctx context.Context, ttl time.Duration) (int, error) {
now := s.now()
cutoff := now.Add(-ttl).Unix()
finishedAt := now.Unix()
rows, err := s.queries.ExpirePendingSessions(ctx, db.ExpirePendingSessionsParams{
FinishedAt: &finishedAt,
Cutoff: cutoff,
})
if err != nil {
return 0, fmt.Errorf("expire pending: %w", err)
}
for _, r := range rows {
ev := hub.Event{
Type: "transfer:state",
Data: stateEventPayload(r.ID, StateCancelled, "", "pending_timeout"),
}
s.hub.SendTo(r.UserID, r.SenderName, ev)
s.hub.SendTo(r.UserID, r.ReceiverName, ev)
s.relay.Release(r.ID)
}
return len(rows), nil
}
+351
View File
@@ -0,0 +1,351 @@
package transfer
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
)
// recHub records SendTo calls without spinning up real SSE clients.
// We can't easily mock *hub.Hub directly because it's a concrete type — instead
// we feed it a fake DeviceLister and ignore the broadcast side; the fact that
// SendTo to a non-connected client returns false is acceptable here.
type recordingLister struct {
mu sync.Mutex
calls int
}
func (r *recordingLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls++
return nil, nil
}
func newTestHub() *hub.Hub {
return hub.New(&recordingLister{})
}
func openTestDB(t *testing.T) *db.Queries {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "x.db")
conn, err := db.Open(dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err)
}
return db.New(conn)
}
func newTestService(t *testing.T) *Service {
t.Helper()
q := openTestDB(t)
h := newTestHub()
t.Cleanup(h.Close)
return NewService(q, h, nil)
}
// fakeRelay records Register/Release calls and can be primed to fail Register,
// standing in for *relay.Manager so the transfer wiring can be exercised without
// importing relay.
type fakeRelay struct {
registers [][4]string // {id, userID, sender, receiver}
releases []string
registerErr error
}
func (f *fakeRelay) Register(id, userID, sender, receiver string) error {
if f.registerErr != nil {
return f.registerErr
}
f.registers = append(f.registers, [4]string{id, userID, sender, receiver})
return nil
}
func (f *fakeRelay) Release(id string) { f.releases = append(f.releases, id) }
func newTestServiceWithRelay(t *testing.T, r RelayController) *Service {
t.Helper()
q := openTestDB(t)
h := newTestHub()
t.Cleanup(h.Close)
return NewService(q, h, r)
}
func TestInit_AllowsZeroByteFile(t *testing.T) {
svc := newTestService(t)
if _, err := svc.Init(context.Background(), InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "empty.txt", FileSize: 0,
}); err != nil {
t.Errorf("0-byte file must be accepted (brief §2 不限大小); got %v", err)
}
}
func TestInit_PersistsPending(t *testing.T) {
svc := newTestService(t)
id, err := svc.Init(context.Background(), InitParams{
UserID: "alice",
SenderName: "tab-1",
ReceiverName: "tab-2",
FileName: "doc.pdf",
FileSize: 1024,
})
if err != nil {
t.Fatalf("init: %v", err)
}
if id == "" {
t.Fatal("expected non-empty id")
}
sess, err := svc.queries.GetTransferSession(context.Background(), id)
if err != nil {
t.Fatalf("get: %v", err)
}
if sess.State != StatePending {
t.Errorf("state: got %q, want %q", sess.State, StatePending)
}
if sess.UserID != "alice" || sess.SenderName != "tab-1" || sess.ReceiverName != "tab-2" {
t.Errorf("unexpected fields: %+v", sess)
}
}
func TestInit_RejectsBadInput(t *testing.T) {
svc := newTestService(t)
bad := []InitParams{
{UserID: "u", SenderName: "", ReceiverName: "r", FileName: "f", FileSize: 1},
{UserID: "u", SenderName: "s", ReceiverName: "", FileName: "f", FileSize: 1},
{UserID: "u", SenderName: "s", ReceiverName: "s", FileName: "f", FileSize: 1}, // self
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "", FileSize: 1},
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "f", FileSize: -1},
}
for i, p := range bad {
if _, err := svc.Init(context.Background(), p); err == nil {
t.Errorf("case %d should error: %+v", i, p)
}
}
}
func TestTransition_HappyPath_PendingToDone(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, err := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 1024,
})
if err != nil {
t.Fatalf("init: %v", err)
}
steps := []struct {
state, mode string
}{
{StateAccepted, ""},
{StateP2PActive, ModeP2P},
{StateDone, ""},
}
for _, step := range steps {
if err := svc.Transition(ctx, "u", id, step.state, step.mode, "", 1024); err != nil {
t.Fatalf("transition to %s: %v", step.state, err)
}
}
sess, _ := svc.queries.GetTransferSession(ctx, id)
if sess.State != StateDone {
t.Errorf("final state: got %q, want %q", sess.State, StateDone)
}
if sess.Mode == nil || *sess.Mode != ModeP2P {
t.Errorf("mode: got %v, want %q", sess.Mode, ModeP2P)
}
if sess.FinishedAt == nil {
t.Error("FinishedAt should be set on terminal state")
}
}
func TestTransition_FallbackToRelayThenDone(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
for _, st := range []string{StateAccepted, StateP2PActive, StateRelayActive, StateDone} {
mode := ""
switch st {
case StateP2PActive:
mode = ModeP2P
case StateRelayActive:
mode = ModeRelay
}
if err := svc.Transition(ctx, "u", id, st, mode, "", 0); err != nil {
t.Fatalf("transition to %s: %v", st, err)
}
}
}
// Entering RELAY_ACTIVE must reserve a relay slot, recording the transfer's two
// participants so the relay endpoints can later reject anyone else (R1/G2).
func TestTransition_RegistersRelayOnFallback(t *testing.T) {
fr := &fakeRelay{}
svc := newTestServiceWithRelay(t, fr)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "laptop", ReceiverName: "phone",
FileName: "f", FileSize: 10,
})
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
t.Fatalf("accept: %v", err)
}
if err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0); err != nil {
t.Fatalf("fallback: %v", err)
}
if len(fr.registers) != 1 {
t.Fatalf("expected 1 Register call, got %d", len(fr.registers))
}
if got := fr.registers[0]; got != [4]string{id, "u", "laptop", "phone"} {
t.Errorf("Register args: got %v", got)
}
}
// When the relay is full, the fallback must NOT advance the transfer into relay
// mode: Transition surfaces ErrRelayUnavailable and the persisted state is
// unchanged so the client can retry.
func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) {
fr := &fakeRelay{registerErr: errors.New("too many")}
svc := newTestServiceWithRelay(t, fr)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
t.Fatalf("accept: %v", err)
}
err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0)
if !errors.Is(err, ErrRelayUnavailable) {
t.Fatalf("expected ErrRelayUnavailable, got %v", err)
}
sess, _ := svc.queries.GetTransferSession(ctx, id)
if sess.State != StateAccepted {
t.Errorf("state must be unchanged on relay-full; got %q, want ACCEPTED", sess.State)
}
}
func TestTransition_RejectsInvalidJump(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
// PENDING → DONE 仍非法(必须先 ACCEPTED 或 P2P_ACTIVE / RELAY_ACTIVE 至少一步)
if err := svc.Transition(ctx, "u", id, StateDone, "", "", 0); err == nil {
t.Error("PENDING→DONE should be rejected")
}
}
func TestTransition_PendingToActiveAllowed(t *testing.T) {
// Race tolerance: sender may call /p2p before receiver's /accept commits.
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
if err := svc.Transition(ctx, "u", id, StateP2PActive, ModeP2P, "", 0); err != nil {
t.Errorf("PENDING→P2P_ACTIVE should be allowed: %v", err)
}
}
func TestTransition_AcceptedToDoneAllowed(t *testing.T) {
// Race tolerance: receiver may finish receiving (call /done) before /p2p ever fired.
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
t.Fatalf("PENDING→ACCEPTED: %v", err)
}
if err := svc.Transition(ctx, "u", id, StateDone, "", "", 10); err != nil {
t.Errorf("ACCEPTED→DONE should be allowed (race-tolerant): %v", err)
}
}
func TestTransition_RejectsForeignUser(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "alice", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
if err := svc.Transition(ctx, "mallory", id, StateAccepted, "", "", 0); err == nil {
t.Error("foreign user must be forbidden")
}
}
func TestTransition_NotFound(t *testing.T) {
svc := newTestService(t)
if err := svc.Transition(context.Background(), "u", "nope", StateAccepted, "", "", 0); err == nil {
t.Error("unknown id should error")
}
}
func TestExpirePending_CancelsOldSessions(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
svc.now = func() time.Time { return old }
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
// jump 200s into the future; PendingTTL is 120s so this should expire.
svc.now = func() time.Time { return old.Add(200 * time.Second) }
n, err := svc.ExpirePending(ctx, PendingTTL)
if err != nil {
t.Fatalf("expire: %v", err)
}
if n != 1 {
t.Errorf("expired count: got %d, want 1", n)
}
sess, _ := svc.queries.GetTransferSession(ctx, id)
if sess.State != StateCancelled {
t.Errorf("state: got %q, want %q", sess.State, StateCancelled)
}
if sess.FailReason == nil || *sess.FailReason != "pending_timeout" {
t.Errorf("fail_reason: got %v, want pending_timeout", sess.FailReason)
}
}
func TestExpirePending_LeavesFreshAlone(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
id, _ := svc.Init(ctx, InitParams{
UserID: "u", SenderName: "s", ReceiverName: "r",
FileName: "f", FileSize: 10,
})
n, err := svc.ExpirePending(ctx, PendingTTL)
if err != nil {
t.Fatalf("expire: %v", err)
}
if n != 0 {
t.Errorf("expired count: got %d, want 0", n)
}
sess, _ := svc.queries.GetTransferSession(ctx, id)
if sess.State != StatePending {
t.Errorf("state: got %q, want PENDING", sess.State)
}
}
+54
View File
@@ -0,0 +1,54 @@
package transfer
const (
StatePending = "PENDING"
StateAccepted = "ACCEPTED"
StateP2PActive = "P2P_ACTIVE"
StateRelayActive = "RELAY_ACTIVE"
StateDone = "DONE"
StateFailed = "FAILED"
StateCancelled = "CANCELLED"
)
const (
ModeP2P = "p2p"
ModeRelay = "relay"
)
// AutoAcceptThreshold is the file size at/under which the receiver may
// silently auto-accept a transfer. brief §2: "> 100 MB 二次确认".
const AutoAcceptThreshold = 100 * 1024 * 1024
// IsValidTransition reports whether a transfer may move from→to.
//
// Forward-only: any state can advance to a "later" state, but never go back.
// We're permissive because client→server timing races are real:
// - sender's /p2p can land before receiver's /accept commits → PENDING → P2P_ACTIVE
// - either side may call /done before /p2p ever fired → ACCEPTED → DONE
// Strict ordering would surface as 409s the user can't recover from.
//
// Terminal states (DONE / FAILED / CANCELLED) accept no further transitions.
func IsValidTransition(from, to string) bool {
switch from {
case StatePending:
return to == StateAccepted || to == StateP2PActive || to == StateRelayActive ||
to == StateCancelled || to == StateFailed
case StateAccepted:
return to == StateP2PActive || to == StateRelayActive ||
to == StateDone || to == StateCancelled || to == StateFailed
case StateP2PActive:
// P2P 失败自动 fallback 到 Relay (brief §2).
return to == StateRelayActive || to == StateDone ||
to == StateFailed || to == StateCancelled
case StateRelayActive:
return to == StateDone || to == StateFailed || to == StateCancelled
case StateDone, StateFailed, StateCancelled:
return false
}
return false
}
// IsTerminal reports whether the state is final (no further transitions).
func IsTerminal(state string) bool {
return state == StateDone || state == StateFailed || state == StateCancelled
}
+54
View File
@@ -0,0 +1,54 @@
package transfer
import "testing"
func TestIsValidTransition(t *testing.T) {
allowed := map[string][]string{
StatePending: {StateAccepted, StateP2PActive, StateRelayActive, StateCancelled, StateFailed},
StateAccepted: {StateP2PActive, StateRelayActive, StateDone, StateCancelled, StateFailed},
StateP2PActive: {StateRelayActive, StateDone, StateFailed, StateCancelled},
StateRelayActive: {StateDone, StateFailed, StateCancelled},
}
all := []string{StatePending, StateAccepted, StateP2PActive, StateRelayActive,
StateDone, StateFailed, StateCancelled}
for from, tos := range allowed {
ok := map[string]bool{}
for _, to := range tos {
ok[to] = true
}
for _, to := range all {
got := IsValidTransition(from, to)
want := ok[to]
if got != want {
t.Errorf("transition %s→%s: got %v, want %v", from, to, got, want)
}
}
}
// Terminals reject all outbound.
for _, term := range []string{StateDone, StateFailed, StateCancelled} {
for _, to := range all {
if IsValidTransition(term, to) {
t.Errorf("terminal %s should not allow transition to %s", term, to)
}
}
}
}
func TestIsTerminal(t *testing.T) {
cases := map[string]bool{
StatePending: false,
StateAccepted: false,
StateP2PActive: false,
StateRelayActive: false,
StateDone: true,
StateFailed: true,
StateCancelled: true,
}
for s, want := range cases {
if got := IsTerminal(s); got != want {
t.Errorf("IsTerminal(%s): got %v, want %v", s, got, want)
}
}
}
+36
View File
@@ -0,0 +1,36 @@
package transfer
import (
"context"
"log/slog"
"time"
)
// PendingTTL is the wall-clock window after which a PENDING session
// is auto-cancelled by the sweeper (plan §4.5 待决策清单 #5).
const PendingTTL = 120 * time.Second
// SweepInterval is how often the sweeper goroutine wakes up.
const SweepInterval = 30 * time.Second
// RunSweeper blocks until ctx is cancelled, periodically expiring
// stale PENDING sessions.
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.ExpirePending(ctx, PendingTTL)
if err != nil {
slog.Error("sweeper: expire pending failed", "err", err)
continue
}
if n > 0 {
slog.Info("sweeper: expired pending sessions", "count", n)
}
}
}
}
View File
+58
View File
@@ -0,0 +1,58 @@
package webui
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed all:dist
var distFS embed.FS
// Handler returns an http.Handler that serves the embedded frontend bundle
// (web/dist copied into ./dist by the Dockerfile). Unmatched paths fall back
// to /index.html so TanStack Router's client-side routes (/login, /setup,
// /oauth/callback, …) work on hard-refresh.
//
// In local dev the embed FS is empty (only .gitkeep), so this returns a 404
// for everything; the frontend is served by `vite dev` on :5173 instead.
func Handler() http.Handler {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
// Should be unreachable; the embed dir always exists.
panic(err)
}
fileServer := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
// Probe the file. If absent, rewrite to "/" so FileServer serves the
// SPA index.html.
served := path
f, err := sub.Open(path)
if err != nil {
r.URL.Path = "/"
served = "index.html"
} else {
_ = f.Close()
}
// 缓存策略:embed.FS 无 modtime → 默认不带任何缓存头,浏览器每次都重取
// (表现为登录跳转回来后 logo 等图片闪烁重载)。按资源类型显式分级:
// - /assets/* 是 Vite 带内容哈希的 bundle → 永久不可变缓存
// - index.html(含 SPA 回退)必须每次校验,才能在新部署后拉到新哈希
// - 其余静态资源(logo / favicon / manifest)极少变 → 一周缓存,
// 使 logo 仅首访下载一次、后续导航与跨页直接命中缓存、不再重载
switch {
case strings.HasPrefix(served, "assets/"):
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case served == "index.html":
w.Header().Set("Cache-Control", "no-cache")
default:
w.Header().Set("Cache-Control", "public, max-age=604800")
}
fileServer.ServeHTTP(w, r)
})
}