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,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
|
||||
}
|
||||
Reference in New Issue
Block a user