diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index 6985a81..7df9769 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -295,12 +295,12 @@ func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc { // 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。 func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s")) + target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s")) if err != nil { c.String(http.StatusForbidden, "%s", err.Error()) return } - contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target) + contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target) if err != nil { c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error()) return diff --git a/internal/repository/reader_repository.go b/internal/repository/reader_repository.go index dd62e4b..8c89707 100644 --- a/internal/repository/reader_repository.go +++ b/internal/repository/reader_repository.go @@ -56,7 +56,12 @@ func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { src := &model.ReaderBookSource{} if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" { - if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil { + // 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引: + // 软删会让这一行继续占着 source_url,之后 SaveSourceState 的 + // First(默认排除软删行)查不到、Create 就会撞唯一约束, + // 表现为「保存书源会话状态失败: UNIQUE constraint failed」, + // cookie / 登录态从此再也存不进去。这里必须硬删。 + if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil { return err } } @@ -79,8 +84,11 @@ func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string) // SaveSourceState 覆盖保存书源会话状态(不存在则新建)。 func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error { + // 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行, + // 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束, + // 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。 var existing model.ReaderSourceState - err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error + err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error if errors.Is(err, gorm.ErrRecordNotFound) { return r.db.WithContext(ctx).Create(st).Error } @@ -89,6 +97,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader } st.ID = existing.ID st.CreatedAt = existing.CreatedAt + if existing.DeletedAt.Valid { + // 历史行被软删过:连 deleted_at 一起写回,把它复活 + st.DeletedAt = gorm.DeletedAt{} + return r.db.WithContext(ctx).Unscoped().Save(st).Error + } return r.db.WithContext(ctx).Save(st).Error } diff --git a/internal/repository/reader_state_test.go b/internal/repository/reader_state_test.go index 907e38e..c6e24b7 100644 --- a/internal/repository/reader_state_test.go +++ b/internal/repository/reader_state_test.go @@ -3,6 +3,7 @@ package repository import ( "errors" "testing" + "time" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -98,6 +99,84 @@ func TestDeleteSourceAlsoClearsState(t *testing.T) { } } +// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。 +// +// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删: +// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束, +// 表现为「保存书源会话状态失败: UNIQUE constraint failed」, +// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。 +func TestSaveSourceStateAfterSourceDeleted(t *testing.T) { + repo := newReaderTestRepo(t) + ctx := t.Context() + const url = "https://d.example.com" + + src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true} + if err := repo.CreateSource(ctx, src); err != nil { + t.Fatal(err) + } + if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{ + SourceURL: url, Cookies: `{"example.com":"a=1"}`, + }); err != nil { + t.Fatal(err) + } + if err := repo.DeleteSource(ctx, src.ID); err != nil { + t.Fatal(err) + } + + // 重新导入同 URL 的书源,再存一次状态 + again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true} + if err := repo.CreateSource(ctx, again); err != nil { + t.Fatal(err) + } + if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{ + SourceURL: url, Cookies: `{"example.com":"a=2"}`, + }); err != nil { + t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err) + } + st, err := repo.GetSourceState(ctx, url) + if err != nil { + t.Fatal(err) + } + if st == nil || st.Cookies != `{"example.com":"a=2"}` { + t.Fatalf("状态未写入: %+v", st) + } +} + +// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。 +// +// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行; +// 保存时必须把它救活,不能去 INSERT 撞唯一约束。 +func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) { + repo := newReaderTestRepo(t) + ctx := t.Context() + const url = "https://e.example.com" + + if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{ + SourceURL: url, Cookies: `{"example.com":"old=1"}`, + }); err != nil { + t.Fatal(err) + } + // 造出老版本 DeleteSource 留下的样子:行还在但被软删 + if err := repo.db.Exec( + "update reader_source_states set deleted_at = ? where source_url = ?", + time.Now(), url).Error; err != nil { + t.Fatal(err) + } + + if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{ + SourceURL: url, Cookies: `{"example.com":"new=2"}`, + }); err != nil { + t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err) + } + st, err := repo.GetSourceState(ctx, url) + if err != nil { + t.Fatal(err) + } + if st == nil || st.Cookies != `{"example.com":"new=2"}` { + t.Fatalf("复活后状态未写入: %+v", st) + } +} + // TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。 func TestImportUpdateKeepsSourceState(t *testing.T) { repo := newReaderTestRepo(t) diff --git a/internal/service/reader/browser_panel.go b/internal/service/reader/browser_panel.go index e4f6f9c..f5ac771 100644 --- a/internal/service/reader/browser_panel.go +++ b/internal/service/reader/browser_panel.go @@ -94,6 +94,10 @@ type pendingBrowser struct { sourceURL string userID string request rule.BrowserTask + // cookies 是发起这次页面的会话 Cookie 累积区:页面在面板里发出的 + // 请求(接口代理 / 资源代理)会把 Set-Cookie 写回它,用户点「完成」后 + // 书源会话就能读到登录凭证。 + cookies browserCookieTarget // html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。 html string finalURL string @@ -113,21 +117,56 @@ type browserHost struct { sourceURL string sourceID string userID string + // state / capture 是这次会话的 Cookie 累积区与 enabledCookieJar 开关。 + // 面板承载的页面自己发起的请求(ProxyBrowserXHR / 资源代理)会把 + // 响应 Set-Cookie 写进 state,从而让「页面里登录成功 = 书源会话拿到凭证」。 + state *sourceState + capture bool } func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) { - return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req) + return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req) } func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error { - return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req) + return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req) +} + +// cookieTarget 打包会话状态与开关;capture 为假时返回携带 nil state 的目标。 +func (h *browserHost) cookieTarget() browserCookieTarget { + if !h.capture { + return browserCookieTarget{} + } + return browserCookieTarget{state: h.state} +} + +// browserCookieTarget 面板链路要回写 Cookie 的目标会话。 +type browserCookieTarget struct { + state *sourceState +} + +// captureFrom 把这次请求(含重定向各跳)下发的 Set-Cookie 写回会话。 +// ctx 上挂了 sink 时以它为准(逐跳收集,见 cookies.go),最后再补最终响应。 +// 归属域一律 Cookie 的 Domain 优先、否则该跳地址。 +func (t browserCookieTarget) captureFrom(ctx context.Context, resp *http.Response) { + if t.state == nil || resp == nil { + return + } + sink := cookieSinkFrom(ctx) + if sink == nil { + sink = &cookieSink{} + } + if resp.Request != nil { + sink.add(resp.Request.URL, resp) + } + sink.apply(t.state) } // ─── 服务层入口 ──────────────────────────────────────────────────────────── // awaitBrowser 登记待办并阻塞等待用户回传页面内容。 -func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) { - entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait) +func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) (rule.BrowserResult, error) { + entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeWait) if err != nil { return rule.BrowserResult{}, err } @@ -151,16 +190,16 @@ func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, u } // openBrowser 登记待办但不等待(页面展示给用户即可)。 -func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error { - _, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen) +func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) error { + _, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeOpen) return err } // registerBrowser 准备页面内容并登记待办。 -func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) { +func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask, mode string) (*pendingBrowser, error) { // 先分配 ID:页面里的资源代理地址需要用它签名。 id := newBrowserID() - html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req) + html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, cookies, req) if err != nil { return nil, err } @@ -173,6 +212,7 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID sourceURL: sourceURL, userID: userID, request: req, + cookies: cookies, html: html, finalURL: finalURL, mode: mode, @@ -319,7 +359,7 @@ func jsStringEscape(s string) string { // 退化成书源站点地址——书源自己的 BaseUrl() 也指向它); // - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端, // 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。 -func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) { +func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, cookies browserCookieTarget, req rule.BrowserTask) (string, string, error) { // 1) 书源自带 HTML(显式 html 参数或 data: URL) if html := strings.TrimSpace(req.HTML); html != "" { return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil @@ -331,7 +371,7 @@ func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id st if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") { return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120)) } - body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL) + body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, cookies, req.URL) if err != nil { return "", "", fmt.Errorf("打开页面失败: %w", err) } @@ -355,8 +395,8 @@ func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, targ } // fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。 -func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) { - contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target) +func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, string, string, error) { + contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, cookies, target) if err != nil { return "", "", "", err } @@ -364,16 +404,25 @@ func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target } // FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。 -// 返回 (contentType, status, body, error)。 -func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) { - contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target) +// 返回 (contentType, status, body, error)。按待办 ID 找会话,顺带把响应 +// Set-Cookie 写回(页面可能靠资源响应续期会话)。 +func (s *ReaderService) FetchBrowserAsset(ctx context.Context, id, target string) (string, int, []byte, error) { + entry := s.lookupBrowser(id) + if entry == nil { + return "", 0, nil, errors.New("页面已过期,请重新打开") + } + contentType, status, data, _, err := s.requestBrowserResource(ctx, entry.sourceURL, entry.cookies, target) return contentType, status, data, err } // requestBrowserResource 带书源凭据请求一个外部地址。 -func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) { +func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, int, []byte, string, error) { reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout) defer cancel() + // 挂上 sink,让重定向各跳的 Set-Cookie 也能被收集。 + if cookies.state != nil { + reqCtx = withCookieSink(reqCtx, &cookieSink{}) + } state := s.newSourceState(reqCtx, sourceURL) httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil) @@ -419,6 +468,8 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, t if resp.Request != nil && resp.Request.URL != nil { finalURL = resp.Request.URL.String() } + // 页面/资源响应下发的 Set-Cookie 也写回会话(含跳转各跳)。 + cookies.captureFrom(reqCtx, resp) contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" @@ -456,6 +507,11 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target } reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout) defer cancel() + // 挂上 sink:页面自己发起的请求(如扫码登录的轮询/取票跳转)下发的 + // Set-Cookie 要能落到书源会话里,否则「面板里登录成功」书源却始终未登录。 + if entry.cookies.state != nil { + reqCtx = withCookieSink(reqCtx, &cookieSink{}) + } httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body)) if err != nil { @@ -506,6 +562,8 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target return nil, err } data = helper.DecompressBody(resp, data) + // 页面请求同样把 Set-Cookie 写回书源会话(含跳转各跳)。 + entry.cookies.captureFrom(reqCtx, resp) contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "text/plain" diff --git a/internal/service/reader/browser_panel_test.go b/internal/service/reader/browser_panel_test.go index 14588ac..6bcf0a0 100644 --- a/internal/service/reader/browser_panel_test.go +++ b/internal/service/reader/browser_panel_test.go @@ -201,7 +201,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) { st.flush() // 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台 - if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{ + if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{ URL: srv.URL + "/user", Title: "用户后台", }); err != nil { t.Fatalf("打开用户后台失败: %v", err) @@ -266,7 +266,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) { if gotSourceURL != srv.URL { t.Fatalf("资源关联的书源 = %q", gotSourceURL) } - contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target) + contentType, status, data, err := svc.FetchBrowserAsset(ctx, page.ID, target) if err != nil { t.Fatalf("拉取资源失败: %v", err) } @@ -313,7 +313,7 @@ func TestBrowserPanelXHRProxy(t *testing.T) { st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456") st.flush() - if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{ + if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{ URL: srv.URL + "/user", Title: "用户后台", }); err != nil { t.Fatalf("打开用户后台失败: %v", err) @@ -403,7 +403,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) { sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com")) // 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新') - if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, + if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{URL: "data:text/html,更新", Title: "光遇书源更新"}); err != nil { t.Fatalf("登记 open 页面失败: %v", err) } @@ -421,7 +421,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) { } // 再点别的按钮:待办表里只应剩新页面,且序号更大 - if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, + if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{URL: "data:text/html,设置", Title: "光遇书源设置"}); err != nil { t.Fatalf("登记新页面失败: %v", err) } diff --git a/internal/service/reader/comment.go b/internal/service/reader/comment.go index 41946f1..1b0a3aa 100644 --- a/internal/service/reader/comment.go +++ b/internal/service/reader/comment.go @@ -451,6 +451,8 @@ func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID, title = "段评" } entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID, + // 段评是「只展示」的页面,没有登录会话可回写 Cookie。 + browserCookieTarget{}, rule.BrowserTask{URL: target, Title: title}, browserModeOpen) if err != nil { return nil, err diff --git a/internal/service/reader/cookie_capture_test.go b/internal/service/reader/cookie_capture_test.go new file mode 100644 index 0000000..031d2e6 --- /dev/null +++ b/internal/service/reader/cookie_capture_test.go @@ -0,0 +1,181 @@ +package reader + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/truewhile/MeBox/internal/service/reader/rule" +) + +// 本文件:响应 Set-Cookie 回写会话的回归测试(cookies.go)。 +// +// 背景:扫码登录类书源把凭证放在**跳转链中间那一跳**的 Set-Cookie 里 +// (B站 的 crossDomain 票据地址就是如此),并且签到站点会声明业务域的 +// Domain(Domain=.bilibili.com)。之前的实现只看最终响应、按请求地址归档, +// 于是凭证永远进不了会话,表现为「面板显示已授权,书源却始终未登录」。 + +// cookieCaptureServer 构造跳转链:/redeem 下发声明了 Domain 的凭证并 302, +// /done 只回 200。这样「中间那一跳的 Set-Cookie」与「最终响应的 Set-Cookie」 +// 能被分开断言。 +func cookieCaptureServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/redeem", func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "SESSDATA", Value: "cred-abc", Domain: ".example.com", Path: "/", + }) + http.Redirect(w, r, "/done", http.StatusFound) + }) + mux.HandleFunc("/done", func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "biz", Value: "1", Path: "/"}) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`面板`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestExecuteCapturesRedirectHopCookies 主链路:跳转中间那一跳的 Set-Cookie +// 必须被收下,且按 Cookie 自己的 Domain 归档(而不是按请求地址)。 +func TestExecuteCapturesRedirectHopCookies(t *testing.T) { + srv := cookieCaptureServer(t) + svc, _ := newLoginTestService(t) + ctx := t.Context() + state := svc.newSourceState(ctx, srv.URL) + + req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}} + if _, _, _, err := svc.executeWithState(ctx, req, state, true); err != nil { + t.Fatalf("请求失败: %v", err) + } + + // 凭证声明的是 .example.com,就应该落在 example.com 这个域桶里。 + if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") { + t.Fatalf("跳转中间那一跳的凭证没有按 Domain 归档: %q", got) + } + // 最终响应的 Cookie 按该跳地址归档。 + if got := state.GetCookie(srv.URL); !strings.Contains(got, "biz=1") { + t.Fatalf("最终响应的 Cookie 没有被收下: %q", got) + } +} + +// TestExecuteSkipsCookieCaptureWhenJarDisabled enabledCookieJar=false 时 +// 主链路不应自动累积 Cookie。 +func TestExecuteSkipsCookieCaptureWhenJarDisabled(t *testing.T) { + srv := cookieCaptureServer(t) + svc, _ := newLoginTestService(t) + ctx := t.Context() + state := svc.newSourceState(ctx, srv.URL) + + req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}} + if _, _, _, err := svc.executeWithState(ctx, req, state, false); err != nil { + t.Fatalf("请求失败: %v", err) + } + if got := state.GetCookie("https://example.com/"); got != "" { + t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got) + } + if got := state.GetCookie(srv.URL); got != "" { + t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got) + } +} + +// TestProxyBrowserXHRCapturesCookies 面板链路:页面自己发起的请求(经 +// ProxyBrowserXHR 转发)拿到的 Set-Cookie 也要写回书源会话并能落库。 +// 这正是「页面里登录成功、书源却始终未登录」的那个缺口。 +func TestProxyBrowserXHRCapturesCookies(t *testing.T) { + srv := cookieCaptureServer(t) + svc, _ := newLoginTestService(t) + ctx := t.Context() + const sourceURL = "https://api.example.com" + const sourceID = "panel-source-1" + state := svc.newSourceState(ctx, sourceURL) + + // 承载页面本身不下发凭证(/plain),确保断言只针对代理请求。 + if err := svc.openBrowser(ctx, sourceURL, sourceID, readerTestUserID, + browserCookieTarget{state: state}, + rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil { + t.Fatalf("登记页面失败: %v", err) + } + pages := svc.PendingBrowserPages(readerTestUserID, sourceID) + if len(pages) != 1 { + t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages)) + } + + // 页面请求票据地址:302 时下发凭证,最终响应只带回 JSON。 + if _, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/redeem", nil, ""); err != nil { + t.Fatalf("代理请求失败: %v", err) + } + if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") { + t.Fatalf("面板请求的凭证没有写回会话: %q", got) + } + + // 登录态必须能持久化:重新读一条会话也应看到这份 Cookie。 + state.flush() + reloaded := svc.newSourceState(ctx, sourceURL) + if got := reloaded.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") { + t.Fatalf("面板拿到的凭证没有落库: %q", got) + } +} + +// TestProxyBrowserXHRWithoutSessionSkipsCapture 没有会话可回写时(如段评面板) +// 代理请求照常工作,只是不做 Cookie 累积。 +func TestProxyBrowserXHRWithoutSessionSkipsCapture(t *testing.T) { + srv := cookieCaptureServer(t) + svc, _ := newLoginTestService(t) + ctx := t.Context() + const sourceID = "panel-source-2" + + if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, + browserCookieTarget{}, + rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil { + t.Fatalf("登记页面失败: %v", err) + } + pages := svc.PendingBrowserPages(readerTestUserID, sourceID) + if len(pages) != 1 { + t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages)) + } + res, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/done", nil, "") + if err != nil { + t.Fatalf("代理请求应照常成功: %v", err) + } + if res.Status != http.StatusOK { + t.Fatalf("代理状态 = %d", res.Status) + } +} + +// TestBrowserHostCookieTargetRespectsJarFlag 面板链路同样受 enabledCookieJar 控制。 +func TestBrowserHostCookieTargetRespectsJarFlag(t *testing.T) { + st := &sourceState{cookies: map[string]string{}} + if got := (&browserHost{state: st, capture: false}).cookieTarget(); got.state != nil { + t.Fatal("enabledCookieJar=false 时不应把会话交给面板回写 Cookie") + } + if got := (&browserHost{state: st, capture: true}).cookieTarget(); got.state != st { + t.Fatal("开启时应把会话交给面板回写 Cookie") + } +} + +// TestSourceStateCookieConcurrency 面板里的资源/接口代理是并发回写 Cookie 的, +// 必须与书源 JS 的读取安全共存(-race 下能抓到缺锁)。 +func TestSourceStateCookieConcurrency(t *testing.T) { + st := &sourceState{cookies: map[string]string{}} + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + url := "https://example.com/" + st.SetCookie(url, "k=1") + _ = st.GetCookie(url) + _ = st.snapshotCookies() + }(i) + } + wg.Wait() + if got := st.GetCookie("https://example.com/"); !strings.Contains(got, "k=1") { + t.Fatalf("并发写入后应能读到 Cookie: %q", got) + } +} diff --git a/internal/service/reader/cookies.go b/internal/service/reader/cookies.go new file mode 100644 index 0000000..c64d193 --- /dev/null +++ b/internal/service/reader/cookies.go @@ -0,0 +1,148 @@ +package reader + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "sync" +) + +// 本文件:把响应下发的 Set-Cookie 收进书源会话。 +// +// 之前只有 executeWithState 收 Set-Cookie,而且只看**最终**响应、按请求地址归档。 +// 对「靠跳转链下发凭证」的登录源(典型是扫码登录:票据地址一跳 302 带 +// Set-Cookie,再跳到业务域名)这三个做法都会漏: +// +// 1. 凭证在中间某一跳的 Set-Cookie 里,只看最终响应拿不到; +// 2. 签发站点(如 passport.biligame.com)声明的 Domain 可能是业务域 +// (Domain=.bilibili.com),按请求地址归档会存进错误的域桶, +// 之后 api.bilibili.com 的请求取不到这份 Cookie; +// 3. 浏览器面板里的页面自己发起的请求走 ProxyBrowserXHR,完全不落库, +// 页面在面板里「登录成功」但书源会话始终是空的。 +// +// 因此统一成:一个 cookieSink 收集一次请求(含各跳)的 Set-Cookie, +// 再按「Cookie 自身的 Domain 优先、否则该跳请求地址」写入会话。 + +// capturedCookie 一条待写入会话的 Cookie 及其归属地址。 +type capturedCookie struct { + // url 归属地址:Cookie 声明了 Domain 时用 Domain(去掉前导点), + // 否则用下发它的那一跳的请求地址。 + url string + cookie string +} + +// cookieSink 收集一次请求(含重定向各跳)下发的 Set-Cookie。 +// 并发安全:面板里的多个资源/接口请求会同时走这里。 +type cookieSink struct { + mu sync.Mutex + items []capturedCookie +} + +// add 收集一份响应里的 Set-Cookie。reqURL 是产生该响应(或跳转前那一跳)的地址。 +func (s *cookieSink) add(reqURL *url.URL, resp *http.Response) { + if s == nil || resp == nil { + return + } + items := make([]capturedCookie, 0, len(resp.Cookies())) + for _, ck := range resp.Cookies() { + if ck == nil || ck.Name == "" { + continue + } + target := "" + if d := strings.TrimSpace(ck.Domain); d != "" { + target = strings.TrimPrefix(d, ".") + } else if reqURL != nil { + target = reqURL.String() + } + if target == "" { + continue + } + items = append(items, capturedCookie{url: target, cookie: ck.Name + "=" + ck.Value}) + } + if len(items) == 0 { + return + } + s.mu.Lock() + s.items = append(s.items, items...) + s.mu.Unlock() +} + +// drain 取出并清空已收集的 Cookie。 +func (s *cookieSink) drain() []capturedCookie { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := s.items + s.items = nil + return out +} + +// apply 把收集到的 Cookie 写入会话并清空(会话为 nil 时直接丢弃)。 +func (s *cookieSink) apply(state *sourceState) { + if s == nil { + return + } + items := s.drain() + if state == nil || len(items) == 0 { + return + } + for _, it := range items { + state.SetCookie(it.url, it.cookie) + } +} + +// ─── 请求级挂载:sink 放进 context,由 CheckRedirect 逐跳收集 ────────────── + +type cookieSinkKey struct{} + +// withCookieSink 在请求 context 上挂一个 sink;sink 为 nil 时原样返回。 +func withCookieSink(ctx context.Context, sink *cookieSink) context.Context { + if sink == nil { + return ctx + } + return context.WithValue(ctx, cookieSinkKey{}, sink) +} + +// cookieSinkFrom 取出请求 context 上的 sink(没有则 nil)。 +func cookieSinkFrom(ctx context.Context) *cookieSink { + if ctx == nil { + return nil + } + sink, _ := ctx.Value(cookieSinkKey{}).(*cookieSink) + return sink +} + +// captureRedirectCookies 作为 http.Client.CheckRedirect: +// 每发生一次跳转,就把「上一跳响应」里的 Set-Cookie 收进 context 上的 sink。 +// +// 没有挂 sink 时是纯透传;跳转次数上限与 net/http 的默认实现保持一致 +// (10 次),所以装上它不改变既有链路的行为。 +func captureRedirectCookies(req *http.Request, via []*http.Request) error { + if req != nil { + if sink := cookieSinkFrom(req.Context()); sink != nil && req.Response != nil { + // via 的最后一跳才是产生 req.Response 的那个请求。 + prev := req + if n := len(via); n > 0 { + prev = via[n-1] + } + sink.add(prev.URL, req.Response) + } + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil +} + +// enableCookieCapture 给 reader 的 HTTP 客户端装上「逐跳收集 Set-Cookie」。 +// NewReaderService 调用一次即可:没有挂 sink 的请求完全不受影响。 +func enableCookieCapture(c *http.Client) { + if c == nil { + return + } + c.CheckRedirect = captureRedirectCookies +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index fb9903e..4931441 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -63,11 +63,14 @@ type ReaderService struct { // NewReaderService 创建服务。 func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService { + client := helper.NewSiteHTTPClient(30, true) + // 逐跳收集 Set-Cookie:登录源常把凭证放在跳转链中间那一跳(见 cookies.go)。 + enableCookieCapture(client) return &ReaderService{ cfg: cfg, log: log, repo: repos.Reader, - http: helper.NewSiteHTTPClient(30, true), + http: client, crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)), limiter: newSourceRateLimiter(), } @@ -319,12 +322,13 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, } } // 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。 - buildRequest := func() (*http.Request, error) { + // reqCtx 每次尝试单独传入,好让 Set-Cookie 只从「最终那次尝试」收集。 + buildRequest := func(reqCtx context.Context) (*http.Request, error) { var bodyReader io.Reader if req.Body != "" { bodyReader = strings.NewReader(req.Body) } - httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader) + httpReq, err := http.NewRequestWithContext(reqCtx, req.Method, target, bodyReader) if err != nil { return nil, err } @@ -361,9 +365,18 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, var ( resp *http.Response data []byte + // sink 收集这次请求(含重定向各跳)下发的 Set-Cookie, + // 只在需要回写会话且书源允许自动累积时才挂。 + sink *cookieSink ) for attempt := 1; ; attempt++ { - httpReq, err := buildRequest() + sink = nil + reqCtx := ctx + if state != nil && captureCookies { + sink = &cookieSink{} + reqCtx = withCookieSink(ctx, sink) + } + httpReq, err := buildRequest(reqCtx) if err != nil { return "", "", 0, err } @@ -405,15 +418,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, body = string(data) } finalURL := resp.Request.URL.String() - // 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取) - var cookieStrs []string - for _, ck := range resp.Cookies() { - cookieStrs = append(cookieStrs, ck.Name+"="+ck.Value) - } - if len(cookieStrs) > 0 && state != nil && captureCookies { - for _, ck := range cookieStrs { - state.SetCookie(finalURL, ck) - } + // 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取)。 + // sink 里已含重定向各跳的 Cookie,这里再把最终响应补进去, + // 归属域按 Cookie 自己的 Domain 优先、否则按该跳地址(见 cookies.go)。 + if sink != nil { + sink.add(resp.Request.URL, resp) + sink.apply(state) } // bodyJs 二次处理 if req.BodyJsFn != nil { @@ -606,6 +616,11 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner { sourceURL: sess.srcURL(), sourceID: sess.srcID(), userID: sess.userID, + // state 交出去,让面板里页面自己发起的请求也能把 Set-Cookie + // 写回这次会话(见 browser_panel.go / cookies.go)。 + state: sess.state, + // capture 对应 enabledCookieJar:关掉时面板同样不自动累积 Cookie。 + capture: sess.captureCookies(), } } return rule.NewJSRunner(rule.JSConfig{ diff --git a/internal/service/reader/state.go b/internal/service/reader/state.go index 395f6ec..b915b4e 100644 --- a/internal/service/reader/state.go +++ b/internal/service/reader/state.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "strings" + "sync" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service/reader/rule" @@ -23,8 +24,12 @@ type sourceState struct { variable string loginInfo string loginHeader string - cookies map[string]string // domain → "k=v; k=v" - loaded bool + // cookies / cookieMu:domain → "k=v; k=v"。 + // cookieMu 保护这张表:浏览器面板里的多个资源/接口代理请求会并发回写 + // Cookie(见 browser_panel.go),而书源 JS 同时在读,不加锁会 data race。 + cookies map[string]string + cookieMu sync.Mutex + loaded bool toasts []string browsers []rule.BrowserRequest @@ -119,6 +124,13 @@ func (st *sourceState) SetLoginHeader(v string) { func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") } func (st *sourceState) GetCookieKey(rawURL, key string) string { + st.cookieMu.Lock() + defer st.cookieMu.Unlock() + return st.getCookieKeyLocked(rawURL, key) +} + +// getCookieKeyLocked 的调用方需持有 cookieMu。 +func (st *sourceState) getCookieKeyLocked(rawURL, key string) string { domain := rule.CookieDomain(rawURL) if domain == "" { return "" @@ -138,6 +150,8 @@ func (st *sourceState) SetCookie(rawURL, cookie string) { if domain == "" || strings.TrimSpace(cookie) == "" { return } + st.cookieMu.Lock() + defer st.cookieMu.Unlock() merged := rule.MergeCookie(st.cookies[domain], cookie) if merged == st.cookies[domain] { return @@ -151,6 +165,8 @@ func (st *sourceState) RemoveCookie(rawURL string) { if domain == "" { return } + st.cookieMu.Lock() + defer st.cookieMu.Unlock() if _, ok := st.cookies[domain]; !ok { return } @@ -197,12 +213,14 @@ func (st *sourceState) flush() { if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" { return } + st.cookieMu.Lock() cookiesJSON := "" if len(st.cookies) > 0 { if b, err := json.Marshal(st.cookies); err == nil { cookiesJSON = string(b) } } + st.cookieMu.Unlock() rec := &model.ReaderSourceState{ SourceURL: st.sourceURL, Variable: st.variable, @@ -221,6 +239,8 @@ func (st *sourceState) flush() { // snapshotCookies 返回 Cookie 副本(domain → cookie 串)。 func (st *sourceState) snapshotCookies() map[string]string { + st.cookieMu.Lock() + defer st.cookieMu.Unlock() out := make(map[string]string, len(st.cookies)) for d, c := range st.cookies { out[d] = c @@ -230,6 +250,8 @@ func (st *sourceState) snapshotCookies() map[string]string { // clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。 func (st *sourceState) clearCookies() { + st.cookieMu.Lock() + defer st.cookieMu.Unlock() if len(st.cookies) == 0 { return }