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,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
|
||||
}
|
||||
Reference in New Issue
Block a user