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