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:
@@ -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] + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 应为 noop,got 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)")
|
||||
}
|
||||
}
|
||||
@@ -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 tokens:iOS 快捷指令用的长效 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 < ?;
|
||||
@@ -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 = ?;
|
||||
@@ -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 = ?;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// (0x20–0x7E), 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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user