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

117 lines
4.5 KiB
Go

package platform
import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
// bootPayload is the state injected into the page at load so the WebView store
// can hydrate synchronously: the restored session (refresh-free — see
// SessionView), the device name (both of which the wails:// scheme can't persist
// in WebView storage), and the API base the page should fetch from.
type bootPayload struct {
Session *SessionView `json:"session"`
DeviceName string `json:"device_name"`
// APIBase points the page at a loopback HTTP origin for /api on Windows,
// where the custom-scheme AssetServer buffers streaming responses (so SSE
// never arrives). Empty on macOS — the page then uses same-origin relative
// /api through the custom scheme, which streams fine there.
APIBase string `json:"api_base,omitempty"`
// DeviceType is the native client kind (macos / windows / linux); the page
// forwards it as X-Device-Type so the backend lists it correctly instead of
// defaulting every device to "browser".
DeviceType string `json:"device_type,omitempty"`
}
// SessionInjectMiddleware injects the boot state into the served index.html as
// `window.__CDROP_BOOT__`, so the WebView store hydrates synchronously at startup
// — no WebView storage (which doesn't survive restart for the wails:// scheme)
// and no binding round-trip (window.go isn't ready that early). Read once at app
// launch. device_name + api_base are injected always (needed even logged out);
// the session is included only when one was restored.
//
// Only the top-level HTML document is rewritten; assets and the /api proxy
// (including the SSE stream) pass straight through untouched. The JSON is
// produced by encoding/json with default HTML escaping, so a display name
// containing "</script>" can't break out of the inline script.
func SessionInjectMiddleware(session *LoginResult, deviceName, apiBase, deviceType string) assetserver.Middleware {
boot := bootPayload{DeviceName: deviceName, APIBase: apiBase, DeviceType: deviceType}
if session != nil {
view := session.View() // strip the refresh_token before it ever reaches the page
boot.Session = &view
}
payload, _ := json.Marshal(boot)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !isDocumentRequest(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// Force a full 200 (never a 304): strip conditional headers so
// http.ServeContent always returns the whole document for us to
// inject. Without this, a WebView that cached the first (logged-out)
// page could send If-Modified-Since on the next launch, get a 304 with
// an empty body, reuse its stale page, and the session wouldn't restore.
r.Header.Del("If-Modified-Since")
r.Header.Del("If-None-Match")
r.Header.Del("If-Range")
rec := &captureWriter{header: http.Header{}}
next.ServeHTTP(rec, r)
body := rec.buf.Bytes()
if idx := bytes.Index(body, []byte("</head>")); idx != -1 {
script := []byte("<script>window.__CDROP_BOOT__=" + string(payload) + ";</script>")
out := make([]byte, 0, len(body)+len(script))
out = append(out, body[:idx]...)
out = append(out, script...)
out = append(out, body[idx:]...)
body = out
}
h := w.Header()
for k, vs := range rec.header {
for _, v := range vs {
h.Add(k, v)
}
}
// Never let the WebView cache the document — the injected session
// changes between launches, so a cached copy must not be reused.
h.Del("Last-Modified")
h.Del("ETag")
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
h.Set("Pragma", "no-cache")
h.Set("Content-Length", strconv.Itoa(len(body)))
code := rec.code
if code == 0 {
code = http.StatusOK
}
w.WriteHeader(code)
_, _ = w.Write(body)
})
}
}
// isDocumentRequest matches the top-level HTML document the WebView loads.
func isDocumentRequest(path string) bool {
return path == "" || path == "/" || path == "/index.html"
}
// captureWriter buffers a handler's response so the middleware can rewrite the
// HTML body before forwarding it. Only used for the small index.html document.
type captureWriter struct {
header http.Header
buf bytes.Buffer
code int
}
func (c *captureWriter) Header() http.Header { return c.header }
func (c *captureWriter) WriteHeader(code int) { c.code = code }
func (c *captureWriter) Write(b []byte) (int, error) { return c.buf.Write(b) }