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:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+154
View File
@@ -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))
}