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

109 lines
3.3 KiB
Go

package platform
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ClipboardMaxBytes mirrors the backend's CDROP_CLIPBOARD_MAX_BYTES (default
// 64 KiB). Uploads larger than this are rejected client-side, matching the web.
const ClipboardMaxBytes = 64 * 1024
// Client is an authenticated client for the cdrop backend API. The access token
// is held here in Go (never in the WebView); deviceName identifies this device
// as the source of clipboard writes via the X-Device-Name header.
type Client struct {
baseURL string
token string
deviceName string
http *http.Client
}
// NewClient builds a Client. baseURL is the backend origin, e.g.
// https://drop.commilitia.net.
func NewClient(baseURL, accessToken, deviceName string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: accessToken,
deviceName: deviceName,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// ClipboardContent is the cloud clipboard state. UpdatedAt is the server's
// authoritative timestamp (also the ETag); 0 means never written.
type ClipboardContent struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
SourceDevice string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
}
func (c *Client) authReq(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if c.deviceName != "" {
req.Header.Set("X-Device-Name", c.deviceName)
}
return req, nil
}
// Clipboard fetches the current cloud clipboard content.
func (c *Client) Clipboard(ctx context.Context) (ClipboardContent, error) {
req, err := c.authReq(ctx, http.MethodGet, "/api/clipboard", nil)
if err != nil {
return ClipboardContent{}, fmt.Errorf("api: build clipboard request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return ClipboardContent{}, fmt.Errorf("api: clipboard request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return ClipboardContent{}, fmt.Errorf("api: GET clipboard status %d", resp.StatusCode)
}
var cc ClipboardContent
if err := json.NewDecoder(resp.Body).Decode(&cc); err != nil {
return ClipboardContent{}, fmt.Errorf("api: decode clipboard: %w", err)
}
return cc, nil
}
// PutClipboard uploads plain text to the cloud clipboard.
func (c *Client) PutClipboard(ctx context.Context, text string) error {
if len(text) > ClipboardMaxBytes {
return fmt.Errorf("api: clipboard content %d bytes exceeds limit %d", len(text), ClipboardMaxBytes)
}
payload, err := json.Marshal(map[string]string{
"content": text,
"content_type": "text/plain",
})
if err != nil {
return fmt.Errorf("api: marshal clipboard: %w", err)
}
req, err := c.authReq(ctx, http.MethodPut, "/api/clipboard", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("api: build clipboard request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("api: clipboard upload: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("api: PUT clipboard status %d", resp.StatusCode)
}
return nil
}