f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
117 lines
4.5 KiB
Go
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) }
|