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
+240
View File
@@ -0,0 +1,240 @@
package httpapi
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// shortcutScope is the single scope a shortcut token is granted; the route layer
// only lets a token carrying it reach the clipboard endpoints (see server.go).
const shortcutScope = "clipboard"
// requireScope gates a route group that scoped shortcut tokens may also reach: a
// scoped token must carry the named scope, while a full login session always
// passes (it has no scope restriction).
func requireScope(scope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Scoped() && !claims.HasScope(scope) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"})
return
}
next.ServeHTTP(w, r)
})
}
}
// rejectScoped blocks scoped shortcut tokens outright — for every route only a
// full login session may touch. A leaked clipboard token thus reaches nothing
// here: not the device list, transfers, signalling, nor token self-management.
func rejectScoped(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
return
}
if claims.Scoped() {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"})
return
}
next.ServeHTTP(w, r)
})
}
// 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token
// 防止泄漏的快捷指令 token 自我续期或越权。
type shortcutIssueReq struct {
Label string `json:"label"`
}
type shortcutIssueResp struct {
// Token 仅在签发时返回这一次,服务端不留可还原副本(只存 jti 等元数据)。
Token string `json:"token"`
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
ExpiresAt int64 `json:"expires_at"`
}
type shortcutTokenView struct {
Jti string `json:"jti"`
Label string `json:"label"`
Scopes []string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt *int64 `json:"last_used_at"`
Revoked bool `json:"revoked"`
}
// handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores
// its metadata, and returns the token string once. Disabled (503) when no HS256
// secret is configured.
func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) {
if len(s.cfg.HS256Secret) == 0 {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
var req shortcutIssueReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
label := strings.TrimSpace(req.Label)
if label == "" {
label = "iOS Shortcut"
}
if len(label) > 64 {
label = label[:64]
}
now := time.Now()
active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{
UserID: claims.UserID,
ExpiresAt: now.Unix(),
})
if err != nil {
slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if active >= int64(s.cfg.ShortcutMaxPerUser) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"})
return
}
jti, err := randomTokenID()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
return
}
expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour)
token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires)
if err != nil {
slog.Error("sign shortcut token failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"})
return
}
if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
Label: label,
Scopes: shortcutScope,
CreatedAt: now.Unix(),
ExpiresAt: expires.Unix(),
}); err != nil {
slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
writeJSON(w, http.StatusCreated, shortcutIssueResp{
Token: token,
Jti: jti,
Label: label,
Scopes: []string{shortcutScope},
ExpiresAt: expires.Unix(),
})
}
// handleShortcutList returns the caller's tokens (metadata only, never the token
// string).
func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID)
if err != nil {
slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
out := make([]shortcutTokenView, 0, len(rows))
for _, t := range rows {
out = append(out, shortcutTokenView{
Jti: t.Jti,
Label: t.Label,
Scopes: strings.Fields(t.Scopes),
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
Revoked: t.Revoked != 0,
})
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}
// handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so
// one user can't revoke another's). verifyHS256 reads the flag on every request,
// so revocation takes effect on the token's next use.
func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
jti := chi.URLParam(r, "jti")
if jti == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"})
return
}
n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{
Jti: jti,
UserID: claims.UserID,
})
if err != nil {
slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if n == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space-
// delimited OAuth-style, informational — the store row is authoritative), iat,
// exp. The shared HS256 secret is the same one verifyHS256 checks against.
func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) {
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)},
(&jose.SignerOptions{}).WithType("JWT"),
)
if err != nil {
return "", err
}
std := jwt.Claims{
Subject: userID,
ID: jti,
IssuedAt: jwt.NewNumericDate(iat),
Expiry: jwt.NewNumericDate(exp),
}
return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
}
// randomTokenID returns a 128-bit URL-safe random id for the jti.
func randomTokenID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}