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
+247
View File
@@ -0,0 +1,247 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
// provider; the access_token still flows back to the browser unchanged.
type authConfigResp struct {
AuthMode string `json:"auth_mode"`
AuthorizeURL string `json:"authorize_url"`
// TokenURL is published so native clients (the desktop app) can run their
// own loopback PKCE flow directly against the provider, reusing this same
// client_id. The browser doesn't need it — it proxies through /api/auth/exchange.
TokenURL string `json:"token_url"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Scopes string `json:"scopes"`
}
// handleAuthConfig publishes everything the frontend needs to start the PKCE
// authorize redirect. No auth required (it's all public OIDC metadata).
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authConfigResp{
AuthMode: s.cfg.AuthMode,
AuthorizeURL: s.cfg.OIDCAuthorizeURL,
TokenURL: s.cfg.OIDCTokenURL,
ClientID: s.cfg.OIDCClientID,
RedirectURI: s.cfg.OIDCRedirectURI,
Scopes: s.cfg.OIDCScopes,
})
}
type exchangeReq struct {
Code string `json:"code"`
CodeVerifier string `json:"code_verifier"`
}
type tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type userResp struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
type exchangeResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
}
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.Code == "" || req.CodeVerifier == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", req.Code)
form.Set("code_verifier", req.CodeVerifier)
form.Set("client_id", s.cfg.OIDCClientID)
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
// Casdoor's "cdrop" application is a confidential client; PKCE is layered
// on top of the standard secret. Skip the secret if not configured to keep
// public-client OIDC providers happy.
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("oidc exchange failed", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
user, err := extractUser(tr.IDToken, tr.AccessToken)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, exchangeResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
User: user,
})
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
type refreshResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.RefreshToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", req.RefreshToken)
form.Set("client_id", s.cfg.OIDCClientID)
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("oidc refresh failed", "err", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
})
}
var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}
func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := oidcHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("oidc unreachable: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var tr tokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("oidc malformed: %w", err)
}
if tr.AccessToken == "" {
return nil, fmt.Errorf("oidc returned no access_token")
}
return &tr, nil
}
// extractUser parses {sub, preferred_username | name} from the id_token if
// available, else falls back to access_token. We DON'T verify the signature
// here — the backend's auth middleware verifies access_token on every API
// call via the JWKS cache, so any tamper would surface there.
func extractUser(idToken, accessToken string) (userResp, error) {
pick := idToken
if pick == "" {
pick = accessToken
}
parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{
jose.RS256, jose.RS384, jose.RS512,
jose.ES256, jose.ES384, jose.ES512,
jose.HS256,
})
if err != nil {
return userResp{}, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
return userResp{}, err
}
u := userResp{ID: std.Subject}
if v, ok := custom["preferred_username"].(string); ok && v != "" {
u.Name = v
} else if v, ok := custom["name"].(string); ok && v != "" {
u.Name = v
} else if v, ok := custom["email"].(string); ok && v != "" {
u.Name = v
} else {
u.Name = u.ID
}
// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段;
// 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。
if v, ok := custom["avatar"].(string); ok && v != "" {
u.Avatar = v
} else if v, ok := custom["picture"].(string); ok && v != "" {
u.Avatar = v
}
return u, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+30
View File
@@ -0,0 +1,30 @@
package httpapi
import (
"log/slog"
"net/http"
"commilitia.net/cdrop/internal/calls"
)
// fallbackICEServers is what we return when Cloudflare is not configured or
// generation fails: STUN-only (Cloudflare anycast, China-friendly).
var fallbackICEServers = []calls.ICEServer{
{URLs: []string{"stun:stun.cloudflare.com:3478"}},
}
// handleCallsCredentials returns ICE servers for the WebRTC client.
// Auth-required (don't expose Cloudflare creds to anonymous probes).
func (s *Server) handleCallsCredentials(w http.ResponseWriter, r *http.Request) {
if s.calls == nil {
writeJSON(w, http.StatusOK, map[string]any{"iceServers": fallbackICEServers})
return
}
creds, err := s.calls.Get(r.Context())
if err != nil {
slog.Warn("cf turn credentials unavailable; serving fallback STUN", "err", err)
writeJSON(w, http.StatusOK, map[string]any{"iceServers": fallbackICEServers})
return
}
writeJSON(w, http.StatusOK, creds)
}
+154
View File
@@ -0,0 +1,154 @@
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))
}
+72
View File
@@ -0,0 +1,72 @@
package httpapi
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
type deviceItem struct {
Name string `json:"name"`
Type string `json:"type"`
Online bool `json:"online"`
LastSeen int64 `json:"last_seen"`
}
// handleDevices returns the calling user's known devices with live online flags.
// Online = currently connected to /api/events; LastSeen = epoch from devices.last_seen.
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID)
if err != nil {
slog.Error("list devices failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
out := make([]deviceItem, 0, len(devs))
for _, d := range devs {
out = append(out, deviceItem{
Name: d.Name,
Type: d.Type,
Online: s.hub.Online(claims.UserID, d.Name),
LastSeen: d.LastSeen,
})
}
writeJSON(w, http.StatusOK, map[string]any{"devices": out})
}
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
name := chi.URLParam(r, "name")
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device name"})
return
}
rows, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
UserID: claims.UserID,
Name: name,
})
if err != nil {
slog.Error("delete device failed", "err", err, "user", claims.UserID, "name", name)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if rows == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"})
return
}
s.hub.Kick(claims.UserID, name)
s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent)
}
+82
View File
@@ -0,0 +1,82 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"time"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
// 4 KB caps DoS-via-paste; longer payloads should use file transfer instead.
const maxMessageBytes = 4 * 1024
// maxMessageBodyBytes bounds the raw request body BEFORE decode (R5): the 4 KB
// limit above checks the decoded Go string, so on its own the whole body is
// still read into memory first. The wire form can be larger than the decoded
// text — JSON escaping inflates quotes/control chars (worst case ~6×) — so the
// body cap is the content cap with headroom, not equal to it, to avoid falsely
// rejecting a legitimate 4 KB message while still bounding memory per request.
const maxMessageBodyBytes = maxMessageBytes * 8
type messageReq struct {
To string `json:"to"`
Text string `json:"text"`
}
// handleMessage forwards a one-shot text payload to a peer device. Reuses the
// SSE Hub the same way /api/hub/signal does — no DB row, no transfer state
// machine. Receiver sees a `message` event; the frontend keeps it in memory
// only until the tab closes (per user spec).
//
// 204 if delivered, 410 if peer offline, 413 if oversized.
func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
from, _ := jwtauth.DeviceNameFromContext(r.Context())
r.Body = http.MaxBytesReader(w, r.Body, maxMessageBodyBytes)
var req messageReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "message exceeds 4 KB"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.To == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to'"})
return
}
if req.Text == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty text"})
return
}
if len(req.Text) > maxMessageBytes {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "message exceeds 4 KB"})
return
}
if req.To == from {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot send to self"})
return
}
delivered := s.hub.SendTo(claims.UserID, req.To, hub.Event{
Type: "message",
Data: map[string]any{
"from": from,
"text": req.Text,
"sent_at": time.Now().Unix(),
},
})
if !delivered {
writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"})
return
}
w.WriteHeader(http.StatusNoContent)
}
+155
View File
@@ -0,0 +1,155 @@
package httpapi
import (
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/relay"
)
// Per-chunk upload limit. Senders split files into chunks of at most this size
// (plan §M5: "sender 多次 chunked POST"). Whole-chunk read into memory before
// commit lets us cleanly reject oversized chunks without partial state.
const maxRelayChunkSize = relay.DefaultChunkLimit
func (s *Server) handleRelayChunk(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
sessionID := chi.URLParam(r, "id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing session id"})
return
}
// Read body fully so an oversized chunk fails *before* we touch the ring.
body, err := io.ReadAll(io.LimitReader(r.Body, maxRelayChunkSize+1))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if len(body) > maxRelayChunkSize {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "chunk exceeds 1 MiB"})
return
}
sess, err := s.relay.Acquire(sessionID, claims.UserID, device)
if err != nil {
mapRelayError(w, err)
return
}
// Resume support (plan §M10): caller sends the offset of THIS chunk's
// first byte. We CAS against current bytesWritten so a retried chunk
// (network blip, sender app reload) returns 409 and the X-Bytes-Received
// header tells the client where to pick up.
if hdr := r.Header.Get("X-Resume-Offset"); hdr != "" {
want, perr := strconv.ParseInt(hdr, 10, 64)
if perr != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid X-Resume-Offset"})
return
}
got := sess.BytesWritten()
if want != got {
w.Header().Set("X-Bytes-Received", strconv.FormatInt(got, 10))
writeJSON(w, http.StatusConflict, map[string]any{
"error": "offset mismatch",
"expected": got,
"received": want,
})
return
}
}
n, err := sess.Write(r.Context(), body)
if err != nil {
slog.Warn("relay chunk write failed",
"err", err, "session", sessionID, "user", claims.UserID, "n", n)
writeJSON(w, http.StatusGone, map[string]string{
"error": err.Error(),
"bytes_received": strconv.FormatInt(sess.BytesWritten(), 10),
})
return
}
if r.Header.Get("X-End") == "true" {
sess.CloseWriter()
}
w.Header().Set("X-Bytes-Received", strconv.FormatInt(sess.BytesWritten(), 10))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRelayStream(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
device, _ := jwtauth.DeviceNameFromContext(r.Context())
sessionID := chi.URLParam(r, "id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing session id"})
return
}
sess, err := s.relay.Acquire(sessionID, claims.UserID, device)
if err != nil {
mapRelayError(w, err)
return
}
// The relay stream is strictly single-consumer: a second concurrent reader
// would drain bytes out of the ring and silently corrupt the receiver's copy
// (G2). Refuse it rather than join. ReleaseReader on return lets a legitimate
// reconnect (network blip, page reload) take over once the first has exited.
if !sess.AcquireReader() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "stream already active"})
return
}
defer sess.ReleaseReader()
flusher, _ := w.(http.Flusher)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
if flusher != nil {
flusher.Flush()
}
buf := make([]byte, 32*1024)
for {
n, err := sess.Read(r.Context(), buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
if flusher != nil {
flusher.Flush()
}
}
if errors.Is(err, io.EOF) {
return
}
if err != nil {
return
}
}
}
func mapRelayError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, relay.ErrTooManySessions):
writeJSON(w, http.StatusServiceUnavailable,
map[string]string{"error": "too many active relay sessions"})
case errors.Is(err, relay.ErrForbidden):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
case errors.Is(err, relay.ErrNotRegistered):
// The transfer isn't in relay mode (never fell back, or the session was
// reaped after going idle). The client should re-issue /fallback.
writeJSON(w, http.StatusConflict, map[string]string{"error": "relay session not active"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
+202
View File
@@ -0,0 +1,202 @@
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/relay"
"commilitia.net/cdrop/internal/transfer"
"commilitia.net/cdrop/internal/webui"
)
type Server struct {
cfg *config.Config
db *sql.DB
queries *db.Queries
auth *jwtauth.Authenticator
hub *hub.Hub
transfers *transfer.Service
relay *relay.Manager
calls *calls.Provider // optional; nil → STUN-only fallback
clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard
mux *chi.Mux
}
func New(
cfg *config.Config,
dbConn *sql.DB,
queries *db.Queries,
auth *jwtauth.Authenticator,
h *hub.Hub,
transfers *transfer.Service,
rm *relay.Manager,
cp *calls.Provider,
clip *clipboard.Service,
) *Server {
s := &Server{
cfg: cfg,
db: dbConn,
queries: queries,
auth: auth,
hub: h,
transfers: transfers,
relay: rm,
calls: cp,
clipboard: clip,
mux: chi.NewRouter(),
}
s.routes()
return s
}
func (s *Server) Handler() http.Handler { return s.mux }
func (s *Server) routes() {
s.mux.Use(middleware.RequestID)
s.mux.Use(middleware.RealIP)
s.mux.Use(middleware.Recoverer)
s.mux.Get("/healthz", s.handleHealth)
// Anything not matched below falls through to the embedded frontend bundle.
// In Docker the binary serves the SPA itself; in local dev the embed FS is
// empty and this 404s — vite dev on :5173 handles the UI instead.
s.mux.NotFound(webui.Handler().ServeHTTP)
s.mux.Route("/api", func(r chi.Router) {
// Public OIDC PKCE plumbing — no auth required (it bootstraps it).
r.Get("/auth/config", s.handleAuthConfig)
// Rate-limit the credential-bearing OAuth endpoints (G4): they proxy to
// the IdP, so without a cap cdrop is a brute-force / token-pivot relay.
// Per-IP (RealIP is mounted above); 60/min is ample for real logins and
// hourly refresh even behind a shared NAT, while choking a scripted flood.
r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(60, time.Minute))
r.Post("/auth/exchange", s.handleAuthExchange)
r.Post("/auth/refresh", s.handleAuthRefresh)
})
// Protected routes. gzip / compress is intentionally NOT mounted —
// it would buffer the SSE stream.
r.Group(func(r chi.Router) {
r.Use(s.auth.Middleware)
// Clipboard is the one capability a scoped shortcut token may reach
// (iOS Shortcut sync) — it must carry the "clipboard" scope. Full
// login sessions pass requireScope unconditionally.
r.Group(func(r chi.Router) {
r.Use(requireScope(shortcutScope))
r.Get("/clipboard", s.handleClipboardGet)
r.Put("/clipboard", s.handleClipboardPut)
// Lightweight version probe — no content; lets pollers (iOS
// Shortcut) detect change cheaply before fetching the full body.
r.Get("/clipboard/version", s.handleClipboardVersion)
})
// Everything else requires a full login session; scoped shortcut
// tokens are rejected, so a leaked token's blast radius stays the
// clipboard and nothing more (including token self-management).
r.Group(func(r chi.Router) {
r.Use(rejectScoped)
r.Get("/me", s.handleMe)
r.Post("/me/disconnect", s.handleDisconnect)
r.Get("/hub/events", s.handleEvents)
r.Post("/hub/signal", s.handleSignal)
r.Post("/message", s.handleMessage)
r.Get("/devices", s.handleDevices)
r.Delete("/devices/{name}", s.handleDeleteDevice)
r.Get("/calls/credentials", s.handleCallsCredentials)
r.Post("/shortcut/issue", s.handleShortcutIssue)
r.Get("/shortcut", s.handleShortcutList)
r.Delete("/shortcut/{jti}", s.handleShortcutRevoke)
r.Route("/transfer", func(r chi.Router) {
r.Post("/initiate", s.handleTransferInit)
r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, ""))
r.Post("/{id}/cancel", s.transitionHandler(transfer.StateCancelled, ""))
r.Post("/{id}/p2p", s.transitionHandler(transfer.StateP2PActive, transfer.ModeP2P))
r.Post("/{id}/fallback", s.transitionHandler(transfer.StateRelayActive, transfer.ModeRelay))
r.Post("/{id}/done", s.transitionHandler(transfer.StateDone, ""))
r.Post("/{id}/fail", s.transitionHandler(transfer.StateFailed, ""))
})
r.Route("/relay/{id}", func(r chi.Router) {
r.Post("/chunk", s.handleRelayChunk)
r.Get("/stream", s.handleRelayStream)
})
})
})
})
}
type healthResp struct {
Status string `json:"status"`
AuthMode string `json:"auth_mode"`
DBOK bool `json:"db_ok"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
resp := healthResp{Status: "ok", AuthMode: s.cfg.AuthMode, DBOK: true}
if err := s.db.PingContext(ctx); err != nil {
resp.Status = "degraded"
resp.DBOK = false
slog.Error("healthz db ping failed", "err", err)
}
writeJSON(w, http.StatusOK, resp)
}
type meResp struct {
UserID string `json:"user_id"`
Groups []string `json:"groups"`
DeviceName string `json:"device_name"`
}
func (s *Server) handleMe(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
}
device, _ := jwtauth.DeviceNameFromContext(r.Context())
writeJSON(w, http.StatusOK, meResp{
UserID: claims.UserID,
Groups: claims.Groups,
DeviceName: device,
})
}
// handleDisconnect 是用户主动告知"本机要下线"的轻量信号(登出 / 关页前)。
// auth 中间件会先 UPSERT device 把 last_seen 更新,本 handler 再 Kick 关闭
// SSE + 立刻广播 presence,让对端不必等 30s 宽限期就看到本设备离线。
func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
deviceName, _ := jwtauth.DeviceNameFromContext(r.Context())
if deviceName == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"})
return
}
s.hub.Kick(claims.UserID, deviceName)
s.hub.PublishPresence(r.Context(), claims.UserID)
w.WriteHeader(http.StatusNoContent)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+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
}
+63
View File
@@ -0,0 +1,63 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
// maxSignalBytes caps the signaling body. WebRTC SDP offers/answers and bundled
// ICE candidates are at most a few KB; the payload is an opaque json.RawMessage
// with no other length check, so without this a single request could stream an
// unbounded body into memory (R4). 64 KiB leaves generous headroom for fat SDP.
const maxSignalBytes = 64 * 1024
type signalReq struct {
To string `json:"to"`
Payload json.RawMessage `json:"payload"`
}
// handleSignal forwards WebRTC offer/answer/ICE candidates between same-user devices.
// Body: { to: deviceName, payload: any-json }
// 204 if delivered to live SSE; 410 if peer offline.
func (s *Server) handleSignal(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
from, _ := jwtauth.DeviceNameFromContext(r.Context())
r.Body = http.MaxBytesReader(w, r.Body, maxSignalBytes)
var req signalReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
writeJSON(w, http.StatusRequestEntityTooLarge,
map[string]string{"error": "signal payload too large"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.To == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to'"})
return
}
if req.To == from {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot signal self"})
return
}
delivered := s.hub.SendTo(claims.UserID, req.To, hub.Event{
Type: "signal",
Data: map[string]any{
"from": from,
"payload": req.Payload,
},
})
if !delivered {
writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"})
return
}
w.WriteHeader(http.StatusNoContent)
}
+100
View File
@@ -0,0 +1,100 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
)
const (
keepaliveInterval = 15 * time.Second
probeFrame = ": hi\n\n"
)
// handleEvents serves the long-lived SSE channel mandated by brief §5.
// Sets X-Accel-Buffering / Cache-Control / flush to defeat reverse-proxy buffering.
// Sends a probe frame immediately so the client can detect first-byte latency.
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
claims, ok := jwtauth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "missing claims", http.StatusUnauthorized)
return
}
deviceName, _ := jwtauth.DeviceNameFromContext(r.Context())
if deviceName == "" {
http.Error(w, "missing device", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, probeFrame); err != nil {
return
}
flusher.Flush()
client := s.hub.Connect(r.Context(), claims.UserID, deviceName)
defer s.hub.Disconnect(client)
slog.Debug("sse connected", "user", claims.UserID, "device", deviceName)
ping := time.NewTicker(keepaliveInterval)
defer ping.Stop()
for {
select {
case ev, ok := <-client.Events():
if !ok {
return
}
if err := writeSSEEvent(w, ev); err != nil {
return
}
flusher.Flush()
case <-ping.C:
if _, err := io.WriteString(w, "event: ping\ndata: {}\n\n"); err != nil {
return
}
flusher.Flush()
// Refresh last_seen so a connection-only client (no other API calls)
// keeps its device row alive past TTL — matches the sliding
// refresh_token semantics requested for device persistence.
_ = s.queries.UpsertDevice(r.Context(), db.UpsertDeviceParams{
UserID: claims.UserID,
Name: deviceName,
Type: jwtauth.DeviceTypeFromContext(r.Context()),
LastSeen: time.Now().Unix(),
})
case <-r.Context().Done():
return
}
}
}
func writeSSEEvent(w io.Writer, ev hub.Event) error {
payload, err := json.Marshal(ev.Data)
if err != nil {
return fmt.Errorf("marshal sse data: %w", err)
}
// SSE frame: "event: <type>\ndata: <json>\n\n".
// JSON is single-line via json.Marshal so no `data:` continuation needed.
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, payload)
return err
}
+94
View File
@@ -0,0 +1,94 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/transfer"
)
type initReq struct {
ReceiverName string `json:"receiver_name"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
FileSHA256 string `json:"file_sha256,omitempty"`
}
type initResp struct {
SessionID string `json:"session_id"`
}
func (s *Server) handleTransferInit(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
sender, _ := jwtauth.DeviceNameFromContext(r.Context())
var req initReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
id, err := s.transfers.Init(r.Context(), transfer.InitParams{
UserID: claims.UserID,
SenderName: sender,
ReceiverName: req.ReceiverName,
FileName: req.FileName,
FileSize: req.FileSize,
FileSHA256: req.FileSHA256,
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, initResp{SessionID: id})
}
// handleTransferTransition is the shared body for accept / cancel / fallback /
// done / fail. The target state and any side-fields are bound by the route.
type transitionReq struct {
Reason string `json:"reason,omitempty"`
BytesTransferred int64 `json:"bytes_transferred,omitempty"`
}
func (s *Server) transitionHandler(target, mode string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
return
}
var req transitionReq
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
}
err := s.transfers.Transition(
r.Context(), claims.UserID, id, target,
mode, req.Reason, req.BytesTransferred,
)
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, transfer.ErrNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, transfer.ErrForbidden):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
case errors.Is(err, transfer.ErrInvalidTransition):
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
case errors.Is(err, transfer.ErrRelayUnavailable):
writeJSON(w, http.StatusServiceUnavailable,
map[string]string{"error": "relay unavailable, retry later"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
}