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

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

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

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

101 lines
2.6 KiB
Go

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
}