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