diff --git a/backend/controllers/plugin_wechat.go b/backend/controllers/plugin_wechat.go index 76ffde6..6db0091 100644 --- a/backend/controllers/plugin_wechat.go +++ b/backend/controllers/plugin_wechat.go @@ -8,6 +8,7 @@ import ( "net/http" "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/controllers/wechatcallback" "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/hooks" "git.pcux.cn/xuans/Smail/hooks/framework" @@ -157,17 +158,12 @@ func WeChatBinding(ctx *appcontext.Context, w http.ResponseWriter, req *http.Req // WeChatBindingCallback 不依赖手机浏览器的登录 Cookie,也不自动完成绑定。 func WeChatBindingCallback(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") service := hooks.PluginConfigService() if !config.IsSetupReady() || service == nil || service.ClaimWeChatBinding(req.Context(), req.URL.Query().Get("state"), req.URL.Query().Get("code")) != nil { - http.Error(w, "授权失败或二维码已失效,请返回 Smail 页面重新扫码。", http.StatusBadRequest) + wechatcallback.Write(w, false) return } - _, _ = w.Write([]byte("微信授权已完成。请返回发起扫码的 Smail 页面,核对微信昵称后点击确认绑定。")) + wechatcallback.Write(w, true) } func TestWeChatMessage(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { diff --git a/backend/controllers/wechatcallback/page.css b/backend/controllers/wechatcallback/page.css new file mode 100644 index 0000000..08d0015 --- /dev/null +++ b/backend/controllers/wechatcallback/page.css @@ -0,0 +1,148 @@ +:root { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + color: #182638; + background: #f3f6fa; + -webkit-text-size-adjust: 100%; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + min-height: 100svh; + padding: 48px 20px 28px; + padding-top: max(48px, env(safe-area-inset-top)); + padding-bottom: max(28px, env(safe-area-inset-bottom)); +} + +.result-card { + width: 100%; + max-width: 440px; + margin: 0 auto; + padding: 28px 24px 24px; + border: 1px solid #e5eaf1; + border-radius: 24px; + background: #fff; + box-shadow: 0 12px 40px rgb(32 56 85 / 5%); + text-align: center; +} + +.brand { + color: #254c8a; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.brand span { + margin-left: 8px; + color: #697789; + font-size: 14px; + font-weight: 400; +} + +.status-icon { + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + margin: 32px auto 16px; + border-radius: 50%; + font-size: 38px; + font-weight: 600; + line-height: 1; +} + +.success { + color: #158057; + background: #e9f7f0; +} + +.warning { + color: #a15b12; + background: #fff4e3; +} + +.status-label { + margin: 0 0 8px; + color: #637186; + font-size: 13px; +} + +h1 { + margin: 0; + font-size: 26px; + line-height: 1.4; + font-weight: 650; +} + +.description { + margin: 12px 0 24px; + color: #526174; + font-size: 15px; + line-height: 1.7; +} + +.steps { + display: grid; + gap: 16px; + margin: 0; + padding: 20px 16px; + border-radius: 16px; + background: #f6f8fb; + list-style: none; + text-align: left; +} + +.steps li { + display: flex; + align-items: flex-start; + gap: 12px; + font-size: 15px; + line-height: 24px; +} + +.step-number { + flex: 0 0 24px; + height: 24px; + border-radius: 50%; + background: #e8eef8; + color: #355987; + font-size: 13px; + font-weight: 600; + text-align: center; +} + +.notice { + margin: 20px 0; + color: #647286; + font-size: 13px; + line-height: 1.8; +} + +footer { + padding-top: 18px; + border-top: 1px solid #edf0f5; + color: #68768a; + font-size: 12px; + line-height: 1.7; +} + +@media (max-width: 360px) { + body { + padding: 24px 12px; + } + + .result-card { + padding: 24px 16px; + } + + .steps { + padding: 16px 12px; + } +} diff --git a/backend/controllers/wechatcallback/page.go b/backend/controllers/wechatcallback/page.go new file mode 100644 index 0000000..91c4f28 --- /dev/null +++ b/backend/controllers/wechatcallback/page.go @@ -0,0 +1,46 @@ +package wechatcallback + +import ( + "bytes" + "crypto/sha256" + _ "embed" + "encoding/base64" + "html/template" + "net/http" +) + +// 页面随主程序发布,无需外部字体、脚本或手机浏览器登录状态。 +// +//go:embed page.html +var pageHTML string + +//go:embed page.css +var pageCSS string + +var resultTemplate = template.Must(template.New("wechat-result").Parse(pageHTML)) + +// Write 只呈现授权结果,不接收身份凭据,也不执行账号绑定。 +func Write(w http.ResponseWriter, authorized bool) { + digest := sha256.Sum256([]byte(pageCSS)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + var body bytes.Buffer + data := struct { + Authorized bool + CSS template.CSS + }{authorized, template.CSS(pageCSS)} // 仅信任编译时内嵌样式,禁止传入请求内容。 + if err := resultTemplate.Execute(&body, data); err != nil { + http.Error(w, "页面暂时无法显示,请返回 Smail 查看绑定状态。", http.StatusInternalServerError) + return + } + status := http.StatusOK + if !authorized { + status = http.StatusBadRequest + } + w.WriteHeader(status) + _, _ = w.Write(body.Bytes()) +} diff --git a/backend/controllers/wechatcallback/page.html b/backend/controllers/wechatcallback/page.html new file mode 100644 index 0000000..4eccdfd --- /dev/null +++ b/backend/controllers/wechatcallback/page.html @@ -0,0 +1,40 @@ + + + + + + + + {{if .Authorized}}微信授权成功{{else}}微信授权未完成{{end}} · Smail + + + +
+
Smail 微信通知
+ {{if .Authorized}} + +

等待确认绑定

+

微信授权成功

+

还差一步,请返回 Smail 确认绑定。

+
    +
  1. 回到发起扫码的 Smail 页面
  2. +
  3. 核对显示的微信昵称
  4. +
  5. 点击「确认绑定」
  6. +
+

本次授权不会自动绑定账号。
请在二维码有效期内完成确认。

+ {{else}} + +

请重新扫码

+

微信授权未完成

+

二维码可能已失效,或本次授权未完成。

+
    +
  1. 回到发起扫码的 Smail 页面
  2. +
  3. 关闭绑定弹窗,重新生成二维码
  4. +
  5. 使用本人微信重新扫码授权
  6. +
+

若已在 Smail 确认绑定,以原页面状态为准。
请勿刷新或重复打开旧授权链接。

+ {{end}} + +
+ + diff --git a/backend/controllers/wechatcallback/page_test.go b/backend/controllers/wechatcallback/page_test.go new file mode 100644 index 0000000..7100703 --- /dev/null +++ b/backend/controllers/wechatcallback/page_test.go @@ -0,0 +1,72 @@ +package wechatcallback + +import ( + "crypto/sha256" + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteAuthorizationResult(t *testing.T) { + for _, test := range []struct { + name string + authorized bool + status int + message string + }{ + {"authorized", true, http.StatusOK, "等待确认绑定"}, + {"expired", false, http.StatusBadRequest, "请重新扫码"}, + } { + t.Run(test.name, func(t *testing.T) { + response := httptest.NewRecorder() + Write(response, test.authorized) + if response.Code != test.status { + t.Fatalf("授权结果状态码错误:%d", response.Code) + } + body := response.Body.String() + for _, expected := range []string{test.message, `lang="zh-CN"`, `width=device-width, initial-scale=1`, "回到发起扫码的 Smail 页面", ""} { + if !strings.Contains(body, expected) { + t.Fatalf("结果页缺少预期内容:%s", expected) + } + } + for _, forbidden := range []string{"