magic-agent 1.0.2 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +78 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +282 -51
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +78 -10
- package/npm/bin/magic-client.js +100 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"net"
|
|
7
|
+
"net/http"
|
|
8
|
+
"net/http/cookiejar"
|
|
9
|
+
"net/url"
|
|
10
|
+
"regexp"
|
|
11
|
+
"strings"
|
|
12
|
+
"time"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
/* 内置浏览器的**服务端中转**。
|
|
16
|
+
*
|
|
17
|
+
* ── 为什么需要它 ──
|
|
18
|
+
* 移动端的内置浏览器是一个 <iframe>。iframe 里的站点是**客户端的 WebView 自己去拉的**,
|
|
19
|
+
* 也就是说走的是**手机的网络**;桌面端只把界面发给了手机,并不参与抓取。要让浏览走桌面端
|
|
20
|
+
* 的网络(例如桌面能上、手机不能上的站点,或内网服务),只能由桌面端**代取**再回给手机。
|
|
21
|
+
*
|
|
22
|
+
* ── 形状 ──
|
|
23
|
+
* 手机 iframe → GET /b/<scheme>/<host><path>?magicToken=…
|
|
24
|
+
* 桌面端 → 按 <scheme>://<host><path> 抓取
|
|
25
|
+
* → HTML 就地改写(把页面里的链接/资源地址都指回本中转)
|
|
26
|
+
* → 回给手机
|
|
27
|
+
*
|
|
28
|
+
* 用**路径前缀**承载目标地址(而不是 ?url= 全量编码):浏览器解析相对地址时会自动保留
|
|
29
|
+
* 前缀,配上注入的 <base> 就能让同站链接继续走中转。令牌只放在**首帧**的查询串里,
|
|
30
|
+
* 首帧响应下发 cookie,后续子资源请求靠 cookie 过鉴权(否则每个 URL 都得带令牌)。
|
|
31
|
+
*
|
|
32
|
+
* ── 刻意不做的事 ──
|
|
33
|
+
* - 不转发上游的 Set-Cookie:上游会话存桌面端的 cookie jar,别去污染手机对本服务的 cookie。
|
|
34
|
+
* - 不把 X-Frame-Options / CSP 透传:本来就是为了让原本禁止内嵌的站点能显示,
|
|
35
|
+
* 这两个头一透传就等于白做。
|
|
36
|
+
* - 不改 <script> 内部的字符串:在 JS 里替换 URL 会砸掉脚本。代价是页内 JS 拼出来的
|
|
37
|
+
* 地址(fetch/XHR/动态跳转)不走中转 —— 这是本方案的已知边界。
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const (
|
|
41
|
+
// browsePrefix 中转路径前缀。
|
|
42
|
+
browsePrefix = "/b/"
|
|
43
|
+
// browseCookie 首帧下发、供子资源请求复用的鉴权 cookie。
|
|
44
|
+
browseCookie = "magic_browse"
|
|
45
|
+
// browseMaxBytes HTML 改写单页size上限;超了直接报错(不静默截断成坏页面)。
|
|
46
|
+
browseMaxBytes = 12 << 20
|
|
47
|
+
// browseUA 伪装成 Android Chrome —— 直接拿 Go 默认 UA 会被不少移动站点降级甚至拒绝。
|
|
48
|
+
// 大陆出口下它会让 Google 跳到 www.google.cn/m(404),可用 --browse-ua 换成桌面 UA。
|
|
49
|
+
browseUA = "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
|
50
|
+
// browseUADesktop 桌面 Chrome:站点给桌面版页面;也让 Google 走香港跳转而不是 CN 移动跳转。
|
|
51
|
+
browseUADesktop = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
var (
|
|
55
|
+
browseBaseRe = regexp.MustCompile(`(?is)<base\b[^>]*>`)
|
|
56
|
+
browseCSPRe = regexp.MustCompile(`(?is)<meta[^>]+http-equiv\s*=\s*["']?Content-Security-Policy["']?[^>]*>`)
|
|
57
|
+
browseScriptRe = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script>`)
|
|
58
|
+
browseAttrRe = regexp.MustCompile(`(?is)\b(href|src|poster|action|data)\s*=\s*("([^"]*)"|'([^']*)')`)
|
|
59
|
+
browseSrcsetRe = regexp.MustCompile(`(?is)\bsrcset\s*=\s*("([^"]*)"|'([^']*)')`)
|
|
60
|
+
browseCSSURLRe = regexp.MustCompile(`(?is)url\(\s*['"]?([^'")]+)['"]?\s*\)`)
|
|
61
|
+
browseImportRe = regexp.MustCompile(`(?is)@import\s+("([^"]*)"|'([^']*)')`)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/* googleHostMap:把会踩到「中国大陆移动跳转」的 Google 入口换到香港域名。
|
|
65
|
+
*
|
|
66
|
+
* 背景:大陆出口 + 移动 UA 时,google.com 会 302 到 http://www.google.cn/m,
|
|
67
|
+
* 而该路径本身就是 Google 的 404(实测)。同一个 UA/IP 下 www.google.com.hk 是 200,
|
|
68
|
+
* 而且给的是**移动版**页面 —— 所以我们只在入口把主机名换掉,其余一律不动。
|
|
69
|
+
*
|
|
70
|
+
* 只映射**搜索首页/搜索**这几个主机:accounts./mail./drive. 等不能动,换了会直接坏。
|
|
71
|
+
*/
|
|
72
|
+
var googleHostMap = map[string]string{
|
|
73
|
+
"google.com": "www.google.com.hk",
|
|
74
|
+
"www.google.com": "www.google.com.hk",
|
|
75
|
+
"google.cn": "www.google.com.hk",
|
|
76
|
+
"www.google.cn": "www.google.com.hk",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func remapBrowseHost(u *url.URL) *url.URL {
|
|
80
|
+
// /sorry/... 是 Google 的「异常流量」人机验证页,只存在于 www.google.com。
|
|
81
|
+
// 把它也换成 .hk 会变成 404,用户连验证都做不了 —— 必须放行。
|
|
82
|
+
if strings.HasPrefix(strings.ToLower(u.Path), "/sorry") {
|
|
83
|
+
return u
|
|
84
|
+
}
|
|
85
|
+
to, ok := googleHostMap[strings.ToLower(u.Hostname())]
|
|
86
|
+
if !ok {
|
|
87
|
+
return u
|
|
88
|
+
}
|
|
89
|
+
if p := u.Port(); p != "" {
|
|
90
|
+
u.Host = net.JoinHostPort(to, p)
|
|
91
|
+
} else {
|
|
92
|
+
u.Host = to
|
|
93
|
+
}
|
|
94
|
+
return u
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// browseUserAgent 决定中转用哪个 UA:显式配置优先,其次识别 mobile/desktop 关键字。
|
|
98
|
+
func (s *Service) browseUserAgent() string {
|
|
99
|
+
ua := strings.TrimSpace(s.opts.BrowseUA)
|
|
100
|
+
switch strings.ToLower(ua) {
|
|
101
|
+
case "":
|
|
102
|
+
return browseUA
|
|
103
|
+
case "mobile":
|
|
104
|
+
return browseUA
|
|
105
|
+
case "desktop", "pc":
|
|
106
|
+
return browseUADesktop
|
|
107
|
+
default:
|
|
108
|
+
return ua
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// handleBrowse 中转入口。
|
|
113
|
+
func (s *Service) handleBrowse(w http.ResponseWriter, r *http.Request) {
|
|
114
|
+
target, ok := browseTarget(r)
|
|
115
|
+
if !ok {
|
|
116
|
+
writeErr(w, http.StatusBadRequest, "用法:/b/<http|https>/<host>/<path>")
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
target = remapBrowseHost(target)
|
|
120
|
+
|
|
121
|
+
// 鉴权:查询令牌(首帧)或 cookie(子资源)。
|
|
122
|
+
if want := strings.TrimSpace(s.opts.Token); want != "" {
|
|
123
|
+
got := strings.TrimSpace(r.URL.Query().Get("magicToken"))
|
|
124
|
+
if got == "" {
|
|
125
|
+
if c, err := r.Cookie(browseCookie); err == nil {
|
|
126
|
+
got = c.Value
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if got != want {
|
|
130
|
+
writeErr(w, http.StatusUnauthorized, "缺少或错误的访问令牌(?magicToken= 或 cookie)")
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if r.URL.Query().Get("magicToken") != "" {
|
|
134
|
+
http.SetCookie(w, &http.Cookie{
|
|
135
|
+
Name: browseCookie, Value: want, Path: "/",
|
|
136
|
+
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 中转自身的访问前缀,例如 http://127.0.0.1:52118。用**请求的 Host** 而不是写死:
|
|
142
|
+
// 手机可能经局域网 IP、回环或隧道访问,取请求自带的 Host 才与它的入口一致。
|
|
143
|
+
// 改写出的地址一律带这个前缀(绝对地址),原因见 browseURL 的注释。
|
|
144
|
+
//
|
|
145
|
+
// 协议要认隧道给的头:cloudflared 等反代在本地这一段是 http(r.TLS 为空),
|
|
146
|
+
// 但**对外是 https**。若照 r.TLS 判成 http,改写出的子资源就是 http,
|
|
147
|
+
// 而页面是 https —— 浏览器按「混合内容」全部拦掉(图片/样式/脚本全裂)。
|
|
148
|
+
scheme := "http"
|
|
149
|
+
if r.TLS != nil {
|
|
150
|
+
scheme = "https"
|
|
151
|
+
}
|
|
152
|
+
if p := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); p != "" {
|
|
153
|
+
scheme = p
|
|
154
|
+
}
|
|
155
|
+
proxyBase := scheme + "://" + r.Host
|
|
156
|
+
|
|
157
|
+
outReq, err := http.NewRequestWithContext(r.Context(), r.Method, target.String(), r.Body)
|
|
158
|
+
if err != nil {
|
|
159
|
+
writeErr(w, http.StatusBadGateway, "构造请求失败:"+err.Error())
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
// 头:优先**原样转发客户端真实的浏览器指纹**(UA + Client Hints + Sec-Fetch)。
|
|
163
|
+
// 为什么要这样:上游的风控(Google 的「异常流量」就是它)会看指纹是否自洽。
|
|
164
|
+
// 只塞一个伪装 UA、却不给配套的 Sec-CH-UA 等头,等于自己举牌说「我是脚本」——
|
|
165
|
+
// 表现就是被跳转到 /sorry/index 人机验证。手机上的 WebView 本来就是真浏览器,
|
|
166
|
+
// 把它的头原样带过去最省事也最稳。客户端没给 UA 时才退回内置伪装。
|
|
167
|
+
ua := strings.TrimSpace(r.Header.Get("User-Agent"))
|
|
168
|
+
if ua == "" {
|
|
169
|
+
ua = s.browseUserAgent()
|
|
170
|
+
}
|
|
171
|
+
outReq.Header.Set("User-Agent", ua)
|
|
172
|
+
// 刻意不带 Accept-Encoding:留给 Go 的 transport 自动协商 gzip 并透明解压,
|
|
173
|
+
// 这样拿到的 body 一定是可改写的明文。
|
|
174
|
+
for _, h := range []string{
|
|
175
|
+
"Accept", "Accept-Language", "Content-Type",
|
|
176
|
+
"Sec-CH-UA", "Sec-CH-UA-Mobile", "Sec-CH-UA-Platform", "Sec-CH-UA-Full-Version-List",
|
|
177
|
+
"Sec-Fetch-Dest", "Sec-Fetch-Mode", "Sec-Fetch-Site", "Sec-Fetch-User",
|
|
178
|
+
"Upgrade-Insecure-Requests",
|
|
179
|
+
} {
|
|
180
|
+
if v := r.Header.Get(h); v != "" {
|
|
181
|
+
outReq.Header.Set(h, v)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
resp, err := s.browseClient().Do(outReq)
|
|
186
|
+
if err != nil {
|
|
187
|
+
writeErr(w, http.StatusBadGateway, "桌面端抓取失败:"+err.Error())
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
defer resp.Body.Close()
|
|
191
|
+
|
|
192
|
+
// 重定向:把 Location 也改成中转地址,否则浏览器一跳出前缀就离开中转了。
|
|
193
|
+
if loc := resp.Header.Get("Location"); loc != "" && resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
|
194
|
+
if abs, perr := target.Parse(loc); perr == nil {
|
|
195
|
+
w.Header().Set("Location", browseURL(abs, proxyBase))
|
|
196
|
+
} else {
|
|
197
|
+
w.Header().Set("Location", loc)
|
|
198
|
+
}
|
|
199
|
+
w.WriteHeader(resp.StatusCode)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
ctype := strings.ToLower(resp.Header.Get("Content-Type"))
|
|
204
|
+
if strings.Contains(ctype, "text/html") {
|
|
205
|
+
body, rerr := readCapped(resp.Body)
|
|
206
|
+
if rerr != nil {
|
|
207
|
+
writeErr(w, http.StatusBadGateway, rerr.Error())
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
211
|
+
w.Header().Set("Cache-Control", "no-store")
|
|
212
|
+
w.WriteHeader(resp.StatusCode)
|
|
213
|
+
_, _ = w.Write(rewriteBrowseHTML(body, target, proxyBase))
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
// CSS 也要改写:样式表里 background-image / @import 引用的图不经过 HTML,
|
|
217
|
+
// 不改写就会按 CSS 文件所在位置解析到错误地址(图全裂)。
|
|
218
|
+
if strings.Contains(ctype, "text/css") {
|
|
219
|
+
body, rerr := readCapped(resp.Body)
|
|
220
|
+
if rerr != nil {
|
|
221
|
+
writeErr(w, http.StatusBadGateway, rerr.Error())
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
225
|
+
w.WriteHeader(resp.StatusCode)
|
|
226
|
+
_, _ = w.Write([]byte(rewriteCSS(string(body), target, proxyBase)))
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 非 HTML:原样透传。上游响应头我们从头到尾没拷贝,所以 X-Frame-Options / CSP
|
|
231
|
+
// 自然不存在 —— 这正是「原本禁止内嵌的站点现在能显示」的原因。
|
|
232
|
+
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
|
233
|
+
w.Header().Set("Content-Type", ct)
|
|
234
|
+
}
|
|
235
|
+
w.WriteHeader(resp.StatusCode)
|
|
236
|
+
_, _ = io.Copy(w, resp.Body)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// readCapped 读响应体,超过上限直接报错(不静默截断成坏页面)。
|
|
240
|
+
func readCapped(r io.Reader) ([]byte, error) {
|
|
241
|
+
b, err := io.ReadAll(io.LimitReader(r, browseMaxBytes+1))
|
|
242
|
+
if err != nil {
|
|
243
|
+
return nil, fmt.Errorf("读取响应失败:%w", err)
|
|
244
|
+
}
|
|
245
|
+
if len(b) > browseMaxBytes {
|
|
246
|
+
return nil, fmt.Errorf("响应过大(超过 %dMB),未做中转", browseMaxBytes>>20)
|
|
247
|
+
}
|
|
248
|
+
return b, nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// browseClient 惰性建中转用 HTTP 客户端:带 cookie jar(按上游 host 存会话),
|
|
252
|
+
// 且**不自动跟随重定向**(我们要自己改写 Location)。
|
|
253
|
+
func (s *Service) browseClient() *http.Client {
|
|
254
|
+
s.browseOnce.Do(func() {
|
|
255
|
+
jar, _ := cookiejar.New(nil)
|
|
256
|
+
s.browseHTTP = &http.Client{
|
|
257
|
+
Timeout: 30 * time.Second,
|
|
258
|
+
Jar: jar,
|
|
259
|
+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
260
|
+
return http.ErrUseLastResponse
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
return s.browseHTTP
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// browseTarget 从请求路径拆出目标 URL:/b/<scheme>/<host>/<path>?<query>。
|
|
268
|
+
// 纯手写拆分,不用正则(路径里允许出现任意字符)。
|
|
269
|
+
func browseTarget(r *http.Request) (*url.URL, bool) {
|
|
270
|
+
rest := strings.TrimPrefix(r.URL.EscapedPath(), browsePrefix)
|
|
271
|
+
i := strings.IndexByte(rest, '/')
|
|
272
|
+
if i < 0 {
|
|
273
|
+
return nil, false
|
|
274
|
+
}
|
|
275
|
+
scheme := rest[:i]
|
|
276
|
+
rest = rest[i+1:]
|
|
277
|
+
host, path := rest, "/"
|
|
278
|
+
if j := strings.IndexByte(rest, '/'); j >= 0 {
|
|
279
|
+
host, path = rest[:j], rest[j:]
|
|
280
|
+
}
|
|
281
|
+
if (scheme != "http" && scheme != "https") || host == "" {
|
|
282
|
+
return nil, false
|
|
283
|
+
}
|
|
284
|
+
raw := scheme + "://" + host + path
|
|
285
|
+
if q := stripMagicToken(r.URL.RawQuery); q != "" {
|
|
286
|
+
raw += "?" + q
|
|
287
|
+
}
|
|
288
|
+
u, err := url.Parse(raw)
|
|
289
|
+
if err != nil {
|
|
290
|
+
return nil, false
|
|
291
|
+
}
|
|
292
|
+
return u, true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// stripMagicToken 去掉中转自己的令牌参数,避免把它连同目标地址一起发给上游站点。
|
|
296
|
+
func stripMagicToken(q string) string {
|
|
297
|
+
if q == "" {
|
|
298
|
+
return ""
|
|
299
|
+
}
|
|
300
|
+
parts := strings.Split(q, "&")
|
|
301
|
+
out := parts[:0]
|
|
302
|
+
for _, p := range parts {
|
|
303
|
+
if p == "" || strings.HasPrefix(p, "magicToken=") {
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
out = append(out, p)
|
|
307
|
+
}
|
|
308
|
+
return strings.Join(out, "&")
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// browseURL 把目标地址映射成中转地址。非 http(s) 原样返回。
|
|
312
|
+
//
|
|
313
|
+
// 产出的是**绝对地址**(带 proxyBase 前缀),而不是 /b/... 这样的根相对地址。
|
|
314
|
+
// 原因:根相对地址按「所在文档 / 样式表的源」解析,而页面里常有**直连加载**的外站样式表
|
|
315
|
+
// 或脚本(由 JS 动态插入,我们改写不到)。它们消费到 url(/b/...) 时会解析回自己的源
|
|
316
|
+
// (实测:被解析成 ms.bdstatic.com/b/...,图片全裂)。绝对地址在任何上下文都指向中转。
|
|
317
|
+
func browseURL(abs *url.URL, proxyBase string) string {
|
|
318
|
+
if abs == nil || (abs.Scheme != "http" && abs.Scheme != "https") {
|
|
319
|
+
if abs == nil {
|
|
320
|
+
return ""
|
|
321
|
+
}
|
|
322
|
+
return abs.String()
|
|
323
|
+
}
|
|
324
|
+
p := abs.EscapedPath()
|
|
325
|
+
if p == "" {
|
|
326
|
+
p = "/"
|
|
327
|
+
}
|
|
328
|
+
out := proxyBase + browsePrefix + abs.Scheme + "/" + abs.Host + p
|
|
329
|
+
if abs.RawQuery != "" {
|
|
330
|
+
out += "?" + abs.RawQuery
|
|
331
|
+
}
|
|
332
|
+
return out
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// rewriteRef 把一个页面里的引用地址解析成绝对地址后再映射到中转。
|
|
336
|
+
// data:/javascript:/mailto: 等非网络协议与纯锚点一律不动,避免把页面改坏。
|
|
337
|
+
func rewriteRef(raw string, page *url.URL, proxyBase string) string {
|
|
338
|
+
t := strings.TrimSpace(raw)
|
|
339
|
+
if t == "" {
|
|
340
|
+
return raw
|
|
341
|
+
}
|
|
342
|
+
low := strings.ToLower(t)
|
|
343
|
+
for _, p := range []string{"data:", "javascript:", "mailto:", "tel:", "blob:", "about:", "#"} {
|
|
344
|
+
if strings.HasPrefix(low, p) {
|
|
345
|
+
return raw
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
ref, err := url.Parse(t)
|
|
349
|
+
if err != nil {
|
|
350
|
+
return raw
|
|
351
|
+
}
|
|
352
|
+
abs := page.ResolveReference(ref)
|
|
353
|
+
if abs.Scheme != "http" && abs.Scheme != "https" {
|
|
354
|
+
return raw
|
|
355
|
+
}
|
|
356
|
+
return browseURL(abs, proxyBase)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// rewriteBrowseHTML 就地改写 HTML:剥掉会妨碍内嵌的头、把所有 URL 指回中转、注入 <base>。
|
|
360
|
+
//
|
|
361
|
+
// 分片处理:<script> 段原样保留(在 JS 里替换 URL 会砸脚本),其余段做属性 / srcset / CSS url() 改写。
|
|
362
|
+
func rewriteBrowseHTML(body []byte, page *url.URL, proxyBase string) []byte {
|
|
363
|
+
html := string(body)
|
|
364
|
+
html = browseBaseRe.ReplaceAllString(html, "")
|
|
365
|
+
html = browseCSPRe.ReplaceAllString(html, "")
|
|
366
|
+
|
|
367
|
+
var b strings.Builder
|
|
368
|
+
last := 0
|
|
369
|
+
for _, loc := range browseScriptRe.FindAllStringIndex(html, -1) {
|
|
370
|
+
b.WriteString(rewriteChunk(html[last:loc[0]], page, proxyBase))
|
|
371
|
+
b.WriteString(html[loc[0]:loc[1]])
|
|
372
|
+
last = loc[1]
|
|
373
|
+
}
|
|
374
|
+
b.WriteString(rewriteChunk(html[last:], page, proxyBase))
|
|
375
|
+
out := b.String()
|
|
376
|
+
|
|
377
|
+
// 注入 <base>:给**没被改写**的相对地址一个基准,指向本中转的页面地址。
|
|
378
|
+
// 改写过的地址现在是绝对的、不依赖 base(早先用根相对地址 + 指向原站的 base,
|
|
379
|
+
// 会被解析回原站导致图片全裂,已修)。
|
|
380
|
+
base := `<base href="` + htmlEscapeAttr(browseURL(page, proxyBase)) + `">`
|
|
381
|
+
if i := strings.Index(strings.ToLower(out), "<head"); i >= 0 {
|
|
382
|
+
if j := strings.IndexByte(out[i:], '>'); j >= 0 {
|
|
383
|
+
k := i + j + 1
|
|
384
|
+
return []byte(out[:k] + base + out[k:])
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return []byte(base + out)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
func rewriteChunk(chunk string, page *url.URL, proxyBase string) string {
|
|
391
|
+
chunk = browseAttrRe.ReplaceAllStringFunc(chunk, func(m string) string {
|
|
392
|
+
sub := browseAttrRe.FindStringSubmatch(m)
|
|
393
|
+
name := sub[1]
|
|
394
|
+
// sub[3]/sub[4] 分别是双/单引号内的值;用原始匹配里的引号字符判定取哪个,
|
|
395
|
+
// 因为「空值」与「另一种引号」在子匹配里都可能表现为空串。
|
|
396
|
+
eq := strings.IndexByte(m, '=')
|
|
397
|
+
rest := strings.TrimLeft(m[eq+1:], " \t")
|
|
398
|
+
q := byte('"')
|
|
399
|
+
val := sub[3]
|
|
400
|
+
if len(rest) > 0 && rest[0] == '\'' {
|
|
401
|
+
q = '\''
|
|
402
|
+
val = sub[4]
|
|
403
|
+
}
|
|
404
|
+
return name + "=" + string(q) + rewriteRef(val, page, proxyBase) + string(q)
|
|
405
|
+
})
|
|
406
|
+
chunk = browseSrcsetRe.ReplaceAllStringFunc(chunk, func(m string) string {
|
|
407
|
+
sub := browseSrcsetRe.FindStringSubmatch(m)
|
|
408
|
+
val := sub[2]
|
|
409
|
+
q := `"`
|
|
410
|
+
if sub[2] == "" {
|
|
411
|
+
val = sub[3]
|
|
412
|
+
q = `'`
|
|
413
|
+
}
|
|
414
|
+
return "srcset=" + q + rewriteSrcset(val, page, proxyBase) + q
|
|
415
|
+
})
|
|
416
|
+
return rewriteCSS(chunk, page, proxyBase)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// rewriteCSS 改写样式里的资源引用:url(...) 与 @import "..."。
|
|
420
|
+
func rewriteCSS(css string, page *url.URL, proxyBase string) string {
|
|
421
|
+
css = browseCSSURLRe.ReplaceAllStringFunc(css, func(m string) string {
|
|
422
|
+
sub := browseCSSURLRe.FindStringSubmatch(m)
|
|
423
|
+
// 统一重建成不带引号的形式:Go 的 RE2 不支持反向引用,没法配对首尾引号。
|
|
424
|
+
return "url(" + rewriteRef(sub[1], page, proxyBase) + ")"
|
|
425
|
+
})
|
|
426
|
+
css = browseImportRe.ReplaceAllStringFunc(css, func(m string) string {
|
|
427
|
+
sub := browseImportRe.FindStringSubmatch(m)
|
|
428
|
+
q := sub[1][:1]
|
|
429
|
+
val := sub[2]
|
|
430
|
+
if q == "'" {
|
|
431
|
+
val = sub[3]
|
|
432
|
+
}
|
|
433
|
+
return "@import " + q + rewriteRef(val, page, proxyBase) + q
|
|
434
|
+
})
|
|
435
|
+
return css
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// rewriteSrcset 处理 srcset:逗号分隔,每段「URL [descriptor]」,只改 URL 那一段。
|
|
439
|
+
func rewriteSrcset(val string, page *url.URL, proxyBase string) string {
|
|
440
|
+
items := strings.Split(val, ",")
|
|
441
|
+
for i, it := range items {
|
|
442
|
+
fields := strings.Fields(strings.TrimSpace(it))
|
|
443
|
+
if len(fields) == 0 {
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
fields[0] = rewriteRef(fields[0], page, proxyBase)
|
|
447
|
+
items[i] = strings.Join(fields, " ")
|
|
448
|
+
}
|
|
449
|
+
return strings.Join(items, ", ")
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// htmlEscapeAttr 只做属性值所需的最小转义。
|
|
453
|
+
func htmlEscapeAttr(s string) string {
|
|
454
|
+
r := strings.NewReplacer("&", "&", `"`, """, "<", "<", ">", ">")
|
|
455
|
+
return r.Replace(s)
|
|
456
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"io"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"net/url"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func mustURL(t *testing.T, s string) *url.URL {
|
|
13
|
+
t.Helper()
|
|
14
|
+
u, err := url.Parse(s)
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatalf("解析 %q 失败:%v", s, err)
|
|
17
|
+
}
|
|
18
|
+
return u
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// testProxyBase 测试用的中转前缀(绝对地址形式)。
|
|
22
|
+
const testProxyBase = "http://p"
|
|
23
|
+
|
|
24
|
+
func TestRemapBrowseHost(t *testing.T) {
|
|
25
|
+
cases := []struct{ in, want string }{
|
|
26
|
+
{"https://google.com/", "https://www.google.com.hk/"},
|
|
27
|
+
{"https://www.google.com/search?q=a", "https://www.google.com.hk/search?q=a"},
|
|
28
|
+
{"https://www.google.com/", "https://www.google.com.hk/"},
|
|
29
|
+
// 其它 Google 主机不能动(换了会直接坏)
|
|
30
|
+
{"https://accounts.google.com/", "https://accounts.google.com/"},
|
|
31
|
+
{"https://mail.google.com/", "https://mail.google.com/"},
|
|
32
|
+
// 已经是 hk 的保持
|
|
33
|
+
{"https://www.google.com.hk/", "https://www.google.com.hk/"},
|
|
34
|
+
// /sorry 是人机验证页,只存在于 www.google.com,不能被换成 .hk
|
|
35
|
+
{"https://www.google.com/sorry/index?continue=x", "https://www.google.com/sorry/index?continue=x"},
|
|
36
|
+
}
|
|
37
|
+
for _, c := range cases {
|
|
38
|
+
if got := remapBrowseHost(mustURL(t, c.in)).String(); got != c.want {
|
|
39
|
+
t.Errorf("remap(%q) = %q,期望 %q", c.in, got, c.want)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func TestBrowseURL(t *testing.T) {
|
|
45
|
+
cases := []struct {
|
|
46
|
+
in, want string
|
|
47
|
+
}{
|
|
48
|
+
{"https://example.com/", "http://p/b/https/example.com/"},
|
|
49
|
+
{"https://example.com/a/b?x=1", "http://p/b/https/example.com/a/b?x=1"},
|
|
50
|
+
{"http://192.168.1.5:8080/x", "http://p/b/http/192.168.1.5:8080/x"},
|
|
51
|
+
{"mailto:a@b.c", "mailto:a@b.c"},
|
|
52
|
+
}
|
|
53
|
+
for _, c := range cases {
|
|
54
|
+
if got := browseURL(mustURL(t, c.in), testProxyBase); got != c.want {
|
|
55
|
+
t.Errorf("browseURL(%q) = %q,期望 %q", c.in, got, c.want)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestStripMagicToken(t *testing.T) {
|
|
61
|
+
cases := []struct{ in, want string }{
|
|
62
|
+
{"magicToken=t&x=1", "x=1"},
|
|
63
|
+
{"x=1&magicToken=t", "x=1"},
|
|
64
|
+
{"x=1", "x=1"},
|
|
65
|
+
{"", ""},
|
|
66
|
+
{"magicToken=t", ""},
|
|
67
|
+
}
|
|
68
|
+
for _, c := range cases {
|
|
69
|
+
if got := stripMagicToken(c.in); got != c.want {
|
|
70
|
+
t.Errorf("stripMagicToken(%q) = %q,期望 %q", c.in, got, c.want)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestRewriteBrowseHTML(t *testing.T) {
|
|
76
|
+
page := mustURL(t, "https://example.com/a/b")
|
|
77
|
+
in := `<html><head><base href="https://example.com/"><title>T</title></head>` +
|
|
78
|
+
`<body>` +
|
|
79
|
+
`<a href="/x">root-rel</a>` +
|
|
80
|
+
`<img src="pic.png">` +
|
|
81
|
+
`<a href="https://other.com/p">abs</a>` +
|
|
82
|
+
`<a href="#top">anchor</a>` +
|
|
83
|
+
`<a href="mailto:a@b.c">mail</a>` +
|
|
84
|
+
`<link rel="stylesheet" href="/s.css">` +
|
|
85
|
+
`<div style="background:url('/bg.png')"></div>` +
|
|
86
|
+
`<img srcset="/1.png 1x, /2.png 2x">` +
|
|
87
|
+
`<script>var s="/y";</script>` +
|
|
88
|
+
`</body></html>`
|
|
89
|
+
out := string(rewriteBrowseHTML([]byte(in), page, testProxyBase))
|
|
90
|
+
|
|
91
|
+
wants := []string{
|
|
92
|
+
`href="http://p/b/https/example.com/x"`,
|
|
93
|
+
`src="http://p/b/https/example.com/a/pic.png"`,
|
|
94
|
+
`href="http://p/b/https/other.com/p"`,
|
|
95
|
+
`href="http://p/b/https/example.com/s.css"`,
|
|
96
|
+
`url(http://p/b/https/example.com/bg.png)`,
|
|
97
|
+
`srcset="http://p/b/https/example.com/1.png 1x, http://p/b/https/example.com/2.png 2x"`,
|
|
98
|
+
`<base href="http://p/b/https/example.com/a/b">`,
|
|
99
|
+
}
|
|
100
|
+
for _, w := range wants {
|
|
101
|
+
if !strings.Contains(out, w) {
|
|
102
|
+
t.Errorf("改写结果缺少 %q\n实际:%s", w, out)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 不该动的:锚点、mailto、脚本内字符串、以及原来的 base 标签(应被剥掉)。
|
|
107
|
+
notWants := []string{
|
|
108
|
+
`href="http://p/b/https/example.com/#top"`,
|
|
109
|
+
`/b/https/a@b.c`,
|
|
110
|
+
`/b/https/example.com/y`, // 脚本里的 "/y" 不应被改
|
|
111
|
+
`<base href="https://example.com/">`,
|
|
112
|
+
}
|
|
113
|
+
for _, nw := range notWants {
|
|
114
|
+
if strings.Contains(out, nw) {
|
|
115
|
+
t.Errorf("改写结果不该包含 %q\n实际:%s", nw, out)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if !strings.Contains(out, `href="#top"`) {
|
|
119
|
+
t.Errorf("锚点应保持原样,实际:%s", out)
|
|
120
|
+
}
|
|
121
|
+
if !strings.Contains(out, `var s="/y";`) {
|
|
122
|
+
t.Errorf("脚本内容应保持原样,实际:%s", out)
|
|
123
|
+
}
|
|
124
|
+
if !strings.Contains(out, `href="mailto:a@b.c"`) {
|
|
125
|
+
t.Errorf("mailto 应保持原样,实际:%s", out)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// TestRewriteCSS 样式表里的资源引用也要改写(否则 background-image 全裂)。
|
|
130
|
+
func TestRewriteCSS(t *testing.T) {
|
|
131
|
+
page := mustURL(t, "https://example.com/css/style.css")
|
|
132
|
+
in := `.a{background:url('/img/a.png')} .b{background:url(../img/b.png)}` +
|
|
133
|
+
`@import "sub.css"; @import url('/other.css');`
|
|
134
|
+
out := rewriteCSS(in, page, testProxyBase)
|
|
135
|
+
wants := []string{
|
|
136
|
+
`url(http://p/b/https/example.com/img/a.png)`,
|
|
137
|
+
`url(http://p/b/https/example.com/img/b.png)`,
|
|
138
|
+
`@import "http://p/b/https/example.com/css/sub.css"`,
|
|
139
|
+
`url(http://p/b/https/example.com/other.css)`,
|
|
140
|
+
}
|
|
141
|
+
for _, w := range wants {
|
|
142
|
+
if !strings.Contains(out, w) {
|
|
143
|
+
t.Errorf("CSS 改写缺少 %q\n实际:%s", w, out)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// TestHandleBrowseE2E 端到端:起一个「拒绝内嵌」的上游站点,经中转取回,
|
|
149
|
+
// 断言 (1) X-Frame-Options 被剥掉、(2) 页内链接被改写到本中转。
|
|
150
|
+
func TestHandleBrowseE2E(t *testing.T) {
|
|
151
|
+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
152
|
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
153
|
+
w.Header().Set("X-Frame-Options", "DENY") // 上游明确拒绝被内嵌
|
|
154
|
+
w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
|
|
155
|
+
_, _ = io.WriteString(w, `<html><head></head><body><a href="/next">next</a></body></html>`)
|
|
156
|
+
}))
|
|
157
|
+
defer upstream.Close()
|
|
158
|
+
|
|
159
|
+
up, err := url.Parse(upstream.URL)
|
|
160
|
+
if err != nil {
|
|
161
|
+
t.Fatal(err)
|
|
162
|
+
}
|
|
163
|
+
s := &Service{} // Token 为空 → 跳过鉴权,专注验证中转与改写
|
|
164
|
+
proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
|
|
165
|
+
defer proxy.Close()
|
|
166
|
+
|
|
167
|
+
resp, err := http.Get(proxy.URL + browsePrefix + "http/" + up.Host + "/")
|
|
168
|
+
if err != nil {
|
|
169
|
+
t.Fatal(err)
|
|
170
|
+
}
|
|
171
|
+
defer resp.Body.Close()
|
|
172
|
+
body, _ := io.ReadAll(resp.Body)
|
|
173
|
+
|
|
174
|
+
if resp.Header.Get("X-Frame-Options") != "" {
|
|
175
|
+
t.Error("X-Frame-Options 应被剥掉(否则站点无法内嵌)")
|
|
176
|
+
}
|
|
177
|
+
if resp.Header.Get("Content-Security-Policy") != "" {
|
|
178
|
+
t.Error("CSP 应被剥掉")
|
|
179
|
+
}
|
|
180
|
+
if !strings.Contains(string(body), browsePrefix+"http/"+up.Host+"/next") {
|
|
181
|
+
t.Errorf("页内链接应被改写到本中转,实际:%s", body)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// TestHandleBrowseAuth 令牌校验:给了令牌就必须带对,否则 401。
|
|
186
|
+
func TestHandleBrowseAuth(t *testing.T) {
|
|
187
|
+
s := &Service{opts: Options{Token: "sekret"}}
|
|
188
|
+
proxy := httptest.NewServer(http.HandlerFunc(s.handleBrowse))
|
|
189
|
+
defer proxy.Close()
|
|
190
|
+
|
|
191
|
+
resp, err := http.Get(proxy.URL + browsePrefix + "https/example.com/")
|
|
192
|
+
if err != nil {
|
|
193
|
+
t.Fatal(err)
|
|
194
|
+
}
|
|
195
|
+
resp.Body.Close()
|
|
196
|
+
if resp.StatusCode != http.StatusUnauthorized {
|
|
197
|
+
t.Errorf("缺令牌应 401,实际 %d", resp.StatusCode)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -134,3 +134,38 @@ func join(v []string) string {
|
|
|
134
134
|
}
|
|
135
135
|
return out
|
|
136
136
|
}
|
|
137
|
+
|
|
138
|
+
// originSource(2026-10-11):移动容器(capacitor/ionic)= 移动端;其余 = 桌面端。
|
|
139
|
+
// 这条决定会话列表上的设备标志,所以两种容器 scheme 都要认。
|
|
140
|
+
// requestSource(2026-10-11):客户端自报优先,没自报才退回按 Origin 猜。
|
|
141
|
+
// 移动端加载 /m/ 与桌面同源,Origin 一样,所以自报是唯一可靠来源。
|
|
142
|
+
func TestRequestSource(t *testing.T) {
|
|
143
|
+
cases := []struct{ client, origin, want string }{
|
|
144
|
+
{"mobile", "https://localhost", "mobile"},
|
|
145
|
+
{"pc", "capacitor://localhost", "pc"},
|
|
146
|
+
{"", "capacitor://localhost", "mobile"},
|
|
147
|
+
{"", "https://localhost", "pc"},
|
|
148
|
+
{" MOBILE ", "", "mobile"},
|
|
149
|
+
{"unknown", "capacitor://localhost", "mobile"},
|
|
150
|
+
}
|
|
151
|
+
for _, c := range cases {
|
|
152
|
+
if got := requestSource(c.client, c.origin); got != c.want {
|
|
153
|
+
t.Errorf("requestSource(%q,%q) = %q want %q", c.client, c.origin, got, c.want)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func TestOriginSource(t *testing.T) {
|
|
159
|
+
cases := map[string]string{
|
|
160
|
+
"capacitor://localhost": "mobile",
|
|
161
|
+
"ionic://localhost": "mobile",
|
|
162
|
+
"https://localhost": "pc",
|
|
163
|
+
"http://127.0.0.1:8791": "pc",
|
|
164
|
+
"": "pc",
|
|
165
|
+
}
|
|
166
|
+
for in, want := range cases {
|
|
167
|
+
if got := originSource(in); got != want {
|
|
168
|
+
t.Errorf("originSource(%q) = %q want %q", in, got, want)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|