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,154 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"commilitia.net/cdrop/internal/clipboard"
|
||||
"commilitia.net/cdrop/internal/jwtauth"
|
||||
)
|
||||
|
||||
type clipboardPutReq struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"content_type"`
|
||||
// OriginTs is the source device's copy time (ms). It's the LWW key: the write
|
||||
// is accepted only if it strictly exceeds the stored one. Clients that omit it
|
||||
// (legacy / transitional) get server-now, so they still sync.
|
||||
OriginTs int64 `json:"origin_ts"`
|
||||
}
|
||||
|
||||
// handleClipboardPut 接受一次剪贴板写入。请求体最大字节限制(MaxBytesReader)
|
||||
// 给上限 + JSON 信封冗余 (×1.5);服务层会再核一次纯内容字节数。
|
||||
func (s *Server) handleClipboardPut(w http.ResponseWriter, r *http.Request) {
|
||||
if s.clipboard == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||
device, _ := jwtauth.DeviceNameFromContext(r.Context())
|
||||
|
||||
maxBody := int64(s.clipboard.MaxBytes() + s.clipboard.MaxBytes()/2 + 256)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
|
||||
|
||||
var req clipboardPutReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// MaxBytesReader 触发时返回 *http.MaxBytesError;统一映射 413。
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body too large"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
|
||||
originTs := req.OriginTs
|
||||
if originTs <= 0 {
|
||||
originTs = time.Now().UnixMilli() // client didn't tag a copy time → use now
|
||||
}
|
||||
|
||||
st, accepted, err := s.clipboard.Put(r.Context(), claims.UserID, req.ContentType, req.Content, device, originTs)
|
||||
switch {
|
||||
case errors.Is(err, clipboard.ErrUnsupportedType):
|
||||
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, clipboard.ErrTooLarge):
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": err.Error()})
|
||||
return
|
||||
case err != nil:
|
||||
slog.Error("clipboard put failed", "err", err, "user", claims.UserID)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("ETag", etagFor(st.Version))
|
||||
if !accepted {
|
||||
// LWW rejected this write — a newer copy already won. Hand back the current
|
||||
// winner so the client reconciles (pulls) instead of clobbering it.
|
||||
writeJSON(w, http.StatusConflict, st)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, st)
|
||||
}
|
||||
|
||||
type clipboardMeta struct {
|
||||
Version int64 `json:"version"`
|
||||
SourceDevice string `json:"source_device"`
|
||||
ContentType string `json:"content_type"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
OriginTs int64 `json:"origin_ts"`
|
||||
}
|
||||
|
||||
// handleClipboardVersion 返回剪贴板版本元信息(不含 content)。给读不了 HTTP 状态
|
||||
// 码的轮询客户端(iOS 快捷指令)做廉价变更探测:版本变了、且来源不是自己,才去
|
||||
// GET 全量内容——避免每轮都全量拉取。
|
||||
func (s *Server) handleClipboardVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if s.clipboard == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"})
|
||||
return
|
||||
}
|
||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||
st, err := s.clipboard.Get(r.Context(), claims.UserID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
st = &clipboard.State{ContentType: clipboard.ContentTypeText}
|
||||
} else if err != nil {
|
||||
slog.Error("clipboard version get failed", "err", err, "user", claims.UserID)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
|
||||
return
|
||||
}
|
||||
|
||||
etag := etagFor(st.Version)
|
||||
w.Header().Set("ETag", etag)
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, clipboardMeta{
|
||||
Version: st.Version,
|
||||
SourceDevice: st.SourceDevice,
|
||||
ContentType: st.ContentType,
|
||||
UpdatedAt: st.UpdatedAt,
|
||||
OriginTs: st.OriginTs,
|
||||
})
|
||||
}
|
||||
|
||||
// handleClipboardGet 返回当前剪贴板内容;支持 If-None-Match: <updated_at>
|
||||
// → 304 节省流量。用户尚未写入时返回 200 + 空 State + ETag "0",让"槽位常驻"
|
||||
// 语义对前端更友好(避免 DevTools Network 红色 404)。
|
||||
func (s *Server) handleClipboardGet(w http.ResponseWriter, r *http.Request) {
|
||||
if s.clipboard == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
claims, _ := jwtauth.ClaimsFromContext(r.Context())
|
||||
st, err := s.clipboard.Get(r.Context(), claims.UserID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
st = &clipboard.State{ContentType: clipboard.ContentTypeText}
|
||||
} else if err != nil {
|
||||
slog.Error("clipboard get failed", "err", err, "user", claims.UserID)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
|
||||
return
|
||||
}
|
||||
|
||||
etag := etagFor(st.Version)
|
||||
w.Header().Set("ETag", etag)
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// etagFor 用版本号的十进制字符串当 ETag——版本是严格单调的,故 ETag 精确反映
|
||||
// 「内容变了没有」(秒级 updated_at 会在同秒两写时撞号)。RFC 9110 的强 ETag。
|
||||
func etagFor(version int64) string {
|
||||
return fmt.Sprintf("\"%s\"", strconv.FormatInt(version, 10))
|
||||
}
|
||||
Reference in New Issue
Block a user