Files
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

156 lines
4.7 KiB
Go

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()})
}
}