f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
101 lines
2.6 KiB
Go
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
|
|
}
|