diff --git a/internal/service/reader/browser_panel.go b/internal/service/reader/browser_panel.go index 4b2e0f2..6981954 100644 --- a/internal/service/reader/browser_panel.go +++ b/internal/service/reader/browser_panel.go @@ -729,8 +729,34 @@ func (s *ReaderService) browserAssetSig(id, target string) string { return s.browserSign("asset|" + id + "|" + target) } -func (s *ReaderService) browserPageURL(id string) string { - return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id) +// browserPageURL 生成 iframe 承载地址。 +// +// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id¶&source +// 读出「哪本书的哪一段、哪个来源」,再据此请求 /para_review。承载地址是我们自己的 +// 代理路径,页面看到的 query 只有 id/s,原地址的参数全丢了——页面于是按缺参数去 +// 请求评论接口,气泡数照样显示,点开却一条段评都没有。 +// +// 所以把原地址的 query 原样拼在签名参数之后:页面看到的 search 与原站一致。 +// 我们自己的 id/s 放最前,Gin 取同名的第一个值,不会被原地址里的同名参数顶掉。 +func (s *ReaderService) browserPageURL(id, target string) string { + u := "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id) + if q := browserTargetQuery(target); q != "" { + u += "&" + q + } + return u +} + +// browserTargetQuery 取原地址的 query(不含 `?`);data: 地址或解析失败时返回空串。 +func browserTargetQuery(target string) string { + target = strings.TrimSpace(target) + if target == "" || strings.HasPrefix(strings.ToLower(target), "data:") { + return "" + } + parsed, err := url.Parse(target) + if err != nil { + return "" + } + return parsed.RawQuery } // browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。 @@ -888,7 +914,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage { Title: e.request.Title, Mode: e.mode, Seq: e.seq, - PageURL: s.browserPageURL(e.id), + PageURL: s.browserPageURL(e.id, e.request.URL), Refetch: e.request.Refetch, SourceID: e.sourceID, TargetURL: target, diff --git a/internal/service/reader/browser_panel_test.go b/internal/service/reader/browser_panel_test.go index 6bcf0a0..0b4be3f 100644 --- a/internal/service/reader/browser_panel_test.go +++ b/internal/service/reader/browser_panel_test.go @@ -167,6 +167,51 @@ func TestBrowserPanelCancelReleasesBlock(t *testing.T) { } } +// TestBrowserPageURLKeepsTargetQuery 承载地址必须原样带上目标地址的 query。 +// +// 光遇聚合的段评页从 location.search 读 item_id/para/source,再据此请求评论接口。 +// 承载地址若只剩我们自己的 id/s,页面就按缺参数去请求:段评气泡上的数字照常有, +// 点开却一条评论都没有。 +func TestBrowserPageURLKeepsTargetQuery(t *testing.T) { + svc, _ := newLoginTestService(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`comment page`)) + })) + defer srv.Close() + + // 目标地址里故意带上与我们同名的 s/id:它们不能顶掉签名参数。 + target := srv.URL + "/get_para_review?item_id=abc¶=1&source=QQ%E9%98%85%E8%AF%BB&s=decoy&id=decoy" + entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID, + browserCookieTarget{}, rule.BrowserTask{URL: target, Title: "段评"}, browserModeOpen) + if err != nil { + t.Fatalf("登记承载页面失败: %v", err) + } + + page := svc.browserPageOf(entry) + u, err := url.Parse(page.PageURL) + if err != nil { + t.Fatalf("承载地址不合法: %q", page.PageURL) + } + q := u.Query() + if q.Get("item_id") != "abc" || q.Get("para") != "1" || q.Get("source") != "QQ阅读" { + t.Fatalf("承载地址丢了目标参数(段评页会因此取不到评论): %q", page.PageURL) + } + // 路由按 id/s 取承载的页面快照,同名参数必须仍指向我们自己的待办 + if q.Get("id") != entry.id || q.Get("s") != svc.browserPageSig(entry.id) { + t.Fatalf("同名参数顶掉了签名参数: %q", page.PageURL) + } + if _, err := svc.VerifyBrowserPage(q.Get("id"), q.Get("s")); err != nil { + t.Fatalf("带业务参数后签名校验失败: %v", err) + } + // 没有 query 的地址(含 data: 页面)不该多出参数 + for _, plain := range []string{"https://panel.example.com/plain", "data:text/html;base64,AAA"} { + if got := svc.browserPageURL("id1", plain); strings.Count(got, "&") != 1 { + t.Fatalf("无 query 的地址被拼进了多余参数: %q", got) + } + } +} + // TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie // 抓取,否则「用户后台」在浏览器里永远是未登录状态。 func TestBrowserPanelInjectsSourceCookies(t *testing.T) {