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

155 lines
5.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}