mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
开发
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 书源登录相关的 HTTP 层测试:路由注册与 JSON 契约。
|
||||
|
||||
func newReaderHandlerContainer(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
// 唯一库名 + cache=shared:阅读链路会并发写会话状态,
|
||||
// 而 :memory: 下每个连接各自一个库,并发写入对后续读取不可见。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Cfg: cfg,
|
||||
Log: zap.NewNop(),
|
||||
Reader: reader.NewReaderService(cfg, zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
// loginHandlerSource 返回一个声明了 loginUrl/loginUi 的书源 JSON。
|
||||
func loginHandlerSource(server string) string {
|
||||
loginJS := `function login(flag) {
|
||||
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
|
||||
var res = java.ajax(baseUrl + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
|
||||
var data = JSON.parse(res);
|
||||
if (data.code == 0) { cookie.setCookie(baseUrl, 'qttoken=' + data.key); java.toast('登录成功'); return true; }
|
||||
java.toast(data.msg || '登录失败'); return false;
|
||||
}`
|
||||
ui, _ := json.Marshal([]map[string]any{
|
||||
{"name": "邮箱", "type": "text"},
|
||||
{"name": "密码", "type": "password"},
|
||||
{"name": "登录", "type": "button", "action": "login(true)"},
|
||||
})
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": server,
|
||||
"bookSourceName": "登录源",
|
||||
"bookSourceType": 0,
|
||||
"loginUrl": loginJS,
|
||||
"loginUi": string(ui),
|
||||
"searchUrl": server + "/search",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "class.item",
|
||||
"name": "tag.h3@tag.a@text",
|
||||
"bookUrl": "tag.h3@tag.a@href",
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(src)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// loginHandlerServer 模拟需要登录的站点。
|
||||
func loginHandlerServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/login_api":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["register_email"] != "u@e.com" || body["password"] != "pw" {
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "HTOKEN_1234567890", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"code":0,"key":"HTOKEN_1234567890"}`))
|
||||
case "/search":
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=HTOKEN_1234567890") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`<html>未登录</html>`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">登录后可见</a></h3></div></body></html>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// registerReaderRoutesForTest 挂载阅读路由(跳过鉴权中间件)。
|
||||
func registerReaderRoutesForTest(cfg *service.Container) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
// TestReaderLoginRoutesEndToEnd 走完整 HTTP 路由:
|
||||
// 查看登录信息 → 执行登录 → 搜索携带 Cookie 成功。
|
||||
func TestReaderLoginRoutesEndToEnd(t *testing.T) {
|
||||
srv := loginHandlerServer(t)
|
||||
defer srv.Close()
|
||||
cfg := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(cfg)
|
||||
|
||||
// 导入书源
|
||||
body, _ := json.Marshal(map[string]string{"text": loginHandlerSource(srv.URL)})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 列表应标注 has_login
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("书源列表 status=%d", w.Code)
|
||||
}
|
||||
var listResp struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
HasLogin bool `json:"has_login"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil || len(listResp.Sources) != 1 {
|
||||
t.Fatalf("书源列表解析失败: %s", w.Body.String())
|
||||
}
|
||||
if !listResp.Sources[0].HasLogin {
|
||||
t.Fatal("has_login 应为 true")
|
||||
}
|
||||
sourceID := listResp.Sources[0].ID
|
||||
|
||||
// GET 登录信息:应返回表单字段与未登录状态
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登录信息 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var info struct {
|
||||
HasLoginJS bool `json:"has_login_js"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("登录信息解析失败: %s", w.Body.String())
|
||||
}
|
||||
if !info.HasLoginJS || info.LoggedIn {
|
||||
t.Fatalf("初始状态异常: %+v", info)
|
||||
}
|
||||
var names []string
|
||||
for _, f := range info.Fields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
for _, want := range []string{"邮箱", "密码", "登录"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("缺少表单字段 %q: %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
// 密码错误 → ok=false,提示透传
|
||||
loginBody, _ := json.Marshal(map[string]any{
|
||||
"fields": map[string]string{"邮箱": "u@e.com", "密码": "bad"},
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var failRes struct {
|
||||
OK bool `json:"ok"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Toasts []string `json:"toasts"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &failRes)
|
||||
if failRes.LoggedIn {
|
||||
t.Fatalf("密码错误不应登录成功: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(strings.Join(failRes.Toasts, " "), "密码错误") {
|
||||
t.Fatalf("失败提示未透传: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 正确密码 → ok=true 且已登录
|
||||
loginBody, _ = json.Marshal(map[string]any{
|
||||
"fields": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var okRes struct {
|
||||
OK bool `json:"ok"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &okRes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !okRes.OK || !okRes.LoggedIn || len(okRes.Cookies) == 0 {
|
||||
t.Fatalf("登录应成功并带 Cookie: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 搜索应携带 Cookie 并拿到结果
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/search", strings.NewReader(`{"key":"任意"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
var searchRes struct {
|
||||
Books []struct{ Name string } `json:"books"`
|
||||
Skipped []struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"skipped"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &searchRes)
|
||||
if len(searchRes.Books) != 1 || searchRes.Books[0].Name != "登录后可见" {
|
||||
t.Fatalf("登录后搜索应成功: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 登出 → 登录态清空
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登出 status=%d", w.Code)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
var afterLogout struct {
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &afterLogout)
|
||||
if afterLogout.LoggedIn || len(afterLogout.Cookies) != 0 {
|
||||
t.Fatalf("登出后应无登录态: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderSetSourceVariableRoute 源变量接口应保存合法 JSON 并拒绝非法 JSON。
|
||||
func TestReaderSetSourceVariableRoute(t *testing.T) {
|
||||
cfg := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"text": `{"bookSourceUrl":"https://v.example.com","bookSourceName":"变量源"}`})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||
var list struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &list)
|
||||
if len(list.Sources) != 1 {
|
||||
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
|
||||
}
|
||||
id := list.Sources[0].ID
|
||||
|
||||
// 合法 JSON
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
|
||||
strings.NewReader(`{"variable":"{\"线路\":\"https://v2.example.com\"}"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("保存变量 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 非法 JSON 应 400
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
|
||||
strings.NewReader(`{"variable":"not-json"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法变量应 400,实际 %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
g.DELETE("/sources/:id", readerDeleteSourceHandler(svc))
|
||||
g.POST("/sources/:id/debug", readerDebugSourceHandler(svc))
|
||||
|
||||
// 书源登录与源变量(登录类书源必需)
|
||||
g.GET("/sources/:id/login", readerSourceLoginInfoHandler(svc))
|
||||
g.POST("/sources/:id/login", readerSourceLoginActionHandler(svc))
|
||||
g.DELETE("/sources/:id/login", readerSourceLogoutHandler(svc))
|
||||
g.PUT("/sources/:id/variable", readerSetSourceVariableHandler(svc))
|
||||
g.PUT("/sources/:id/login-info", readerSetSourceLoginInfoHandler(svc))
|
||||
|
||||
// 搜索(多源聚合)
|
||||
g.POST("/search", readerSearchHandler(svc))
|
||||
|
||||
@@ -122,6 +129,81 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
info, err := svc.Reader.GetSourceLogin(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
// Action 为 loginUi 里按钮的 action;留空表示执行 login()(确认登录)。
|
||||
Action string `json:"action"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := svc.Reader.RunLoginAction(c.Request.Context(), c.Param("id"), body.Action, body.Fields)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Variable string `json:"variable"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package helper — AES-256-GCM at-rest secret encryption.
|
||||
//
|
||||
// Sensitive values (third-party API keys, service passwords, book-source
|
||||
// login credentials) are stored in the database. Encrypting them keyed off
|
||||
// a per-install secret means a stolen DB file alone is not enough to
|
||||
// recover the plaintext.
|
||||
//
|
||||
// Format on disk: "enc:v1:" + base64(nonce || ciphertext || tag).
|
||||
// Legacy plaintext rows (no prefix) round-trip unchanged so an upgraded
|
||||
// install needs no migration step.
|
||||
package helper
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EncPrefix tags ciphertext rows so they can be told apart from plaintext.
|
||||
const EncPrefix = "enc:v1:"
|
||||
|
||||
// SecretCipher encrypts/decrypts strings with AES-256-GCM derived from a
|
||||
// stable per-install secret. A zero-value SecretCipher (empty key) is a
|
||||
// pass-through, which keeps unit tests and keyless dev setups working.
|
||||
type SecretCipher struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// NewSecretCipher derives a 256-bit key from secret via SHA-256.
|
||||
func NewSecretCipher(secret string) *SecretCipher {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return &SecretCipher{}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(sum[:])
|
||||
if err != nil {
|
||||
return &SecretCipher{}
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return &SecretCipher{}
|
||||
}
|
||||
return &SecretCipher{aead: aead}
|
||||
}
|
||||
|
||||
// Ready 表示是否真的能加解密(密钥有效)。
|
||||
func (c *SecretCipher) Ready() bool { return c != nil && c.aead != nil }
|
||||
|
||||
// Encrypt returns the prefixed base64 ciphertext for plain.
|
||||
// Empty inputs and already-encrypted values round-trip unchanged.
|
||||
func (c *SecretCipher) Encrypt(plain string) string {
|
||||
if plain == "" || !c.Ready() || strings.HasPrefix(plain, EncPrefix) {
|
||||
return plain
|
||||
}
|
||||
nonce := make([]byte, c.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return plain
|
||||
}
|
||||
sealed := c.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return EncPrefix + base64.StdEncoding.EncodeToString(sealed)
|
||||
}
|
||||
|
||||
// Decrypt returns the plaintext for a stored value. Plaintext rows (no
|
||||
// prefix) are returned unchanged, as are values that fail to authenticate.
|
||||
func (c *SecretCipher) Decrypt(value string) string {
|
||||
if value == "" || !c.Ready() || !strings.HasPrefix(value, EncPrefix) {
|
||||
return value
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, EncPrefix))
|
||||
if err != nil || len(data) < c.aead.NonceSize() {
|
||||
return value
|
||||
}
|
||||
nonce, body := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
|
||||
plain, err := c.aead.Open(nil, nonce, body, nil)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return string(plain)
|
||||
}
|
||||
|
||||
// IsEncrypted reports whether value carries the encrypted prefix.
|
||||
func (c *SecretCipher) IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, EncPrefix)
|
||||
}
|
||||
@@ -62,6 +62,7 @@ func AllModels() []interface{} {
|
||||
&ScrapeTask{},
|
||||
&EmbyMount{},
|
||||
&ReaderBookSource{},
|
||||
&ReaderSourceState{},
|
||||
&ReaderBook{},
|
||||
&ReaderChapter{},
|
||||
&ReaderReplaceRule{},
|
||||
|
||||
@@ -26,6 +26,20 @@ type ReaderBookSource struct {
|
||||
LastUpdateTime int64 `json:"last_update_time"`
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
||||
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
||||
HasLogin bool `gorm:"-" json:"has_login"`
|
||||
}
|
||||
|
||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
||||
// sourceVariable / userInfo(登录信息)/ loginHeader 与 CookieStore。
|
||||
// 与书源分表存放,避免每次导入更新书源时把用户登录态覆盖掉。
|
||||
type ReaderSourceState struct {
|
||||
Base
|
||||
SourceURL string `gorm:"type:varchar(512);uniqueIndex" json:"source_url"`
|
||||
Variable string `gorm:"type:text" json:"variable"` // source.getVariable/setVariable
|
||||
LoginInfo string `gorm:"type:text" json:"login_info"` // source.getLoginInfo/putLoginInfo(登录表单 JSON)
|
||||
LoginHeader string `gorm:"type:text" json:"login_header"` // source 登录请求头 JSON
|
||||
Cookies string `gorm:"type:text" json:"cookies"` // JSON: domain → "k=v; k=v"
|
||||
}
|
||||
|
||||
// ReaderBook 书架条目(含阅读进度,对应 legado Book)。
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -50,9 +51,45 @@ func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBo
|
||||
return r.db.WithContext(ctx).Save(src).Error
|
||||
}
|
||||
|
||||
// DeleteSource 删除书源。
|
||||
// DeleteSource 删除书源(连带清理其会话状态)。
|
||||
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.ReaderBookSource{}, "id = ?", id).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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// GetSourceState 取书源会话状态;不存在返回 (nil, nil)。
|
||||
func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string) (*model.ReaderSourceState, error) {
|
||||
var out model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
|
||||
var existing model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(st).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.ID = existing.ID
|
||||
st.CreatedAt = existing.CreatedAt
|
||||
return r.db.WithContext(ctx).Save(st).Error
|
||||
}
|
||||
|
||||
// ListBooks 用户书架(按 order 排序)。
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func newReaderTestRepo(t *testing.T) *ReaderRepository {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return New(db).Reader
|
||||
}
|
||||
|
||||
// TestSourceStateRoundTrip 验证会话状态按书源 URL 存取。
|
||||
func TestSourceStateRoundTrip(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 不存在时返回 (nil, nil),调用方据此走默认值
|
||||
got, err := repo.GetSourceState(ctx, "https://a.example.com")
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("首次读取应为空: got=%v err=%v", got, err)
|
||||
}
|
||||
|
||||
st := &model.ReaderSourceState{
|
||||
SourceURL: "https://a.example.com",
|
||||
Variable: `{"线路":"v1"}`,
|
||||
LoginInfo: `{"邮箱":"u@e.com"}`,
|
||||
LoginHeader: `{"X-Token":"t"}`,
|
||||
Cookies: `{"example.com":"qttoken=abc"}`,
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = repo.GetSourceState(ctx, "https://a.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LoginInfo != st.LoginInfo || got.Variable != st.Variable {
|
||||
t.Fatalf("读回不一致: %+v", got)
|
||||
}
|
||||
|
||||
// 再次保存应为更新而非插入(同 URL 唯一)
|
||||
st.LoginInfo = `{"邮箱":"new@e.com"}`
|
||||
if err := repo.SaveSourceState(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := repo.db.Model(&model.ReaderSourceState{}).
|
||||
Where("source_url = ?", "https://a.example.com").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("同一书源应只有一行状态,实际 %d", count)
|
||||
}
|
||||
got, _ = repo.GetSourceState(ctx, "https://a.example.com")
|
||||
if got.LoginInfo != `{"邮箱":"new@e.com"}` {
|
||||
t.Fatalf("更新未生效: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteSourceAlsoClearsState 删除书源应连带清理登录态。
|
||||
func TestDeleteSourceAlsoClearsState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://b.example.com", Enabled: true}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: "https://b.example.com", LoginInfo: `{"a":"b"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := repo.DeleteSource(ctx, src.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, "https://b.example.com")
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st != nil {
|
||||
t.Fatalf("书源删除后登录态应一并清理,实际仍在: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
|
||||
func TestImportUpdateKeepsSourceState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://c.example.com", RawJSON: `{"v":1}`}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: "https://c.example.com", LoginInfo: `{"k":"keep"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟导入更新:只改书源自身字段
|
||||
src.RawJSON = `{"v":2}`
|
||||
if err := repo.UpdateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, "https://c.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.LoginInfo != `{"k":"keep"}` {
|
||||
t.Fatalf("更新书源后登录态丢失: %+v", st)
|
||||
}
|
||||
}
|
||||
+15
-59
@@ -12,96 +12,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
)
|
||||
|
||||
// encPrefix tags ciphertext rows so we can tell them apart from legacy
|
||||
// plaintext values.
|
||||
const encPrefix = "enc:v1:"
|
||||
// plaintext values. Kept as an alias of the shared helper's prefix so both
|
||||
// implementations stay wire-compatible.
|
||||
const encPrefix = helper.EncPrefix
|
||||
|
||||
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
|
||||
// secret (the JWT secret).
|
||||
//
|
||||
// The cipher itself lives in helper.SecretCipher so lower layers (e.g. the
|
||||
// reader subsystem, which cannot import this package) can share one
|
||||
// implementation; this type keeps the service-layer logging and API.
|
||||
type CryptoService struct {
|
||||
log *zap.Logger
|
||||
aead cipher.AEAD
|
||||
log *zap.Logger
|
||||
cipher *helper.SecretCipher
|
||||
}
|
||||
|
||||
// NewCryptoService derives a 256-bit key from the given secret via
|
||||
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
|
||||
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
|
||||
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
|
||||
c := &CryptoService{log: log}
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return c
|
||||
}
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
block, err := aes.NewCipher(sum[:])
|
||||
if err != nil {
|
||||
log.Error("crypto: aes.NewCipher", zap.Error(err))
|
||||
return c
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
log.Error("crypto: cipher.NewGCM", zap.Error(err))
|
||||
return c
|
||||
}
|
||||
c.aead = aead
|
||||
return c
|
||||
return &CryptoService{log: log, cipher: helper.NewSecretCipher(secret)}
|
||||
}
|
||||
|
||||
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
|
||||
// Empty inputs round-trip unchanged.
|
||||
func (c *CryptoService) Encrypt(plain string) string {
|
||||
if plain == "" || c.aead == nil {
|
||||
return plain
|
||||
}
|
||||
if strings.HasPrefix(plain, encPrefix) {
|
||||
return plain
|
||||
}
|
||||
nonce := make([]byte, c.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return plain
|
||||
}
|
||||
cipherBytes := c.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return encPrefix + base64.StdEncoding.EncodeToString(cipherBytes)
|
||||
return c.cipher.Encrypt(plain)
|
||||
}
|
||||
|
||||
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
|
||||
// (no prefix) are returned unchanged.
|
||||
func (c *CryptoService) Decrypt(value string) string {
|
||||
if c == nil || value == "" || c.aead == nil {
|
||||
return value
|
||||
}
|
||||
if !strings.HasPrefix(value, encPrefix) {
|
||||
return value
|
||||
}
|
||||
raw := strings.TrimPrefix(value, encPrefix)
|
||||
data, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
if len(data) < c.aead.NonceSize() {
|
||||
return value
|
||||
}
|
||||
nonce, cipherBytes := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
|
||||
plain, err := c.aead.Open(nil, nonce, cipherBytes, nil)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return string(plain)
|
||||
return c.cipher.Decrypt(value)
|
||||
}
|
||||
|
||||
// IsEncrypted returns true if value carries the encrypted prefix.
|
||||
func (c *CryptoService) IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, encPrefix)
|
||||
return c.cipher.IsEncrypted(value)
|
||||
}
|
||||
|
||||
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:书源登录(对应 legado SourceLoginDialog / SourceLoginViewModel)。
|
||||
//
|
||||
// legado 的登录模型:
|
||||
// - loginUrl 是一段 JS,既是登录逻辑,也是 loginUi 各按钮 action 的函数库;
|
||||
// - loginUi 是一份表单描述(RowUi 数组:text / password / button / toggle / select);
|
||||
// - 点按钮时执行 "loginUrl + '\n' + action",作用域里 result 是"表单当前值",
|
||||
// 返回值被丢弃,只有抛异常才算失败;
|
||||
// - 点"登录"确认按钮时执行 loginUrl 里的 login() 函数(不存在则报错)。
|
||||
//
|
||||
// 服务端无法弹窗,因此把 java.toast / java.startBrowser 的调用收集起来回传前端。
|
||||
|
||||
// LoginField 登录表单的一个控件(对应 legado RowUi)。
|
||||
type LoginField struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // text / password / button / toggle / select
|
||||
Action string `json:"action,omitempty"`
|
||||
Chars []string `json:"chars,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
// ViewName 按钮/标签文案;legado 中它本身可以是 JS 表达式。
|
||||
ViewName string `json:"viewName,omitempty"`
|
||||
Style map[string]any `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// SourceLoginInfo 书源登录界面描述与当前状态。
|
||||
type SourceLoginInfo struct {
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
// HasLoginJS loginUrl 提供了登录逻辑。
|
||||
HasLoginJS bool `json:"has_login_js"`
|
||||
// LoginJS 登录逻辑 JS 原文(前端只作展示/调试,不执行)。
|
||||
LoginJS string `json:"login_js,omitempty"`
|
||||
// Fields 登录表单控件。
|
||||
Fields []LoginField `json:"fields"`
|
||||
// Values 已保存的登录信息(表单回填)。
|
||||
Values map[string]string `json:"values"`
|
||||
// Cookies 当前已保存的 Cookie(domain → cookie 串)。
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
// Variable 源变量 JSON 原文(供变量编辑器)。
|
||||
Variable string `json:"variable"`
|
||||
// VariableComment 源变量说明(书源作者写的填写指引)。
|
||||
VariableComment string `json:"variable_comment,omitempty"`
|
||||
// LoggedIn 是否已具备登录态(存了登录信息或 Cookie)。
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
// LoginFieldsHint loginUi 解析或执行的失败原因(不阻断展示)。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LoginResult 登录动作执行结果。
|
||||
type LoginResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Toasts []string `json:"toasts,omitempty"`
|
||||
Browsers []rule.BrowserRequest `json:"browsers,omitempty"`
|
||||
// Values 执行后的登录信息(可能与执行前不同,如 checkStatus 回填邮箱)。
|
||||
Values map[string]string `json:"values"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
}
|
||||
|
||||
// GetSourceLogin 返回书源登录界面描述与当前登录状态。
|
||||
func (s *ReaderService) GetSourceLogin(ctx context.Context, sourceID string) (*SourceLoginInfo, error) {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
state := sess.state
|
||||
|
||||
info := &SourceLoginInfo{
|
||||
SourceID: src.ID,
|
||||
SourceName: firstNonEmpty(src.Name, bs.BookSourceName),
|
||||
HasLoginJS: strings.TrimSpace(SPtr(bs.LoginURL)) != "",
|
||||
LoginJS: bs.LoginJS(),
|
||||
Fields: []LoginField{},
|
||||
Values: map[string]string{},
|
||||
Cookies: state.snapshotCookies(),
|
||||
Variable: state.GetVariable(),
|
||||
VariableComment: SPtr(bs.VariableComment),
|
||||
}
|
||||
if m, err := parseLoginInfoValues(state.GetLoginInfo()); err == nil {
|
||||
info.Values = m
|
||||
}
|
||||
// 登录态以「是否拿到凭证(Cookie)」为准:
|
||||
// 只填过表单并不等于已登录,否则失败的登录也会显示为已登录。
|
||||
info.LoggedIn = len(info.Cookies) > 0
|
||||
|
||||
fields, err := s.resolveLoginFields(sess, bs, info.Values)
|
||||
if err != nil {
|
||||
info.Error = err.Error()
|
||||
// loginUi 解析失败时若只有 loginUrl,仍可用「打开登录页」方式登录。
|
||||
if SPtr(bs.LoginUI) == "" {
|
||||
info.Error = ""
|
||||
}
|
||||
} else {
|
||||
info.Fields = fields
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// resolveLoginFields 解析 loginUi:直接是 JSON 数组时直接解析;
|
||||
// 是 @js:/<js> 时先执行得到 JSON(对应 legado evalUiJs)。
|
||||
// values 为已保存的登录信息,作为 result 注入(legado 的 loginUi JS 会读它)。
|
||||
func (s *ReaderService) resolveLoginFields(sess *sourceSession, bs *BookSource, values map[string]string) ([]LoginField, error) {
|
||||
raw := strings.TrimSpace(SPtr(bs.LoginUI))
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("书源未配置登录界面(loginUi)")
|
||||
}
|
||||
if isJSWrapped(raw) {
|
||||
runner := sess.runner("", 0)
|
||||
v, err := runner.EvalAction(bs.LoginJS()+"\n"+stripJSWrapper(raw), loginBindings(sess, values))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loginUi JS 执行失败: %w", err)
|
||||
}
|
||||
raw = strings.TrimSpace(anyToStr(v))
|
||||
}
|
||||
var fields []LoginField
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return nil, fmt.Errorf("loginUi 不是合法的 JSON 表单: %w", err)
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// RunLoginAction 执行一个登录动作。
|
||||
//
|
||||
// action 为 loginUi 里某个控件的 action(如 "login(true)" / "checkStatus()");
|
||||
// fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。
|
||||
// action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。
|
||||
func (s *ReaderService) RunLoginAction(ctx context.Context, sourceID, action string, fields map[string]string) (*LoginResult, error) {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
state := sess.state
|
||||
|
||||
loginJS := bs.LoginJS()
|
||||
if loginJS == "" {
|
||||
return nil, fmt.Errorf("书源未配置登录逻辑(loginUrl)")
|
||||
}
|
||||
|
||||
// 合并登录信息:已保存值 + 本次提交值(对应 legado getLoginData)。
|
||||
values := map[string]string{}
|
||||
if m, err := parseLoginInfoValues(state.GetLoginInfo()); err == nil {
|
||||
values = m
|
||||
}
|
||||
for k, v := range fields {
|
||||
values[k] = v
|
||||
}
|
||||
// 若提供了新的表单值,先持久化(legado 在调用登录函数前先存 loginInfo)。
|
||||
if len(fields) > 0 {
|
||||
if b, err := json.Marshal(values); err == nil {
|
||||
state.SetLoginInfo(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
body := loginJS + "\n"
|
||||
if strings.TrimSpace(action) == "" {
|
||||
// 对应 legado:login() 必须由书源实现
|
||||
body += "if (typeof login=='function'){ login.apply(this); } else { throw('Function login not implements!!!'); }"
|
||||
} else {
|
||||
body += action
|
||||
}
|
||||
|
||||
runner := sess.runner("", 0)
|
||||
_, runErr := runner.EvalAction(body, loginBindings(sess, values))
|
||||
|
||||
// 无论成功失败都要落库:Cookie/变量可能已被部分改写(如已拿到 token 但后续步骤报错)。
|
||||
state.flush()
|
||||
|
||||
res := &LoginResult{
|
||||
OK: runErr == nil,
|
||||
Toasts: state.toasts,
|
||||
Browsers: state.browsers,
|
||||
Values: values,
|
||||
Cookies: state.snapshotCookies(),
|
||||
}
|
||||
if runErr != nil {
|
||||
res.Error = runErr.Error()
|
||||
}
|
||||
// 登录态以是否拿到凭证(Cookie)为准,避免"只存了表单"被显示成已登录。
|
||||
res.LoggedIn = len(res.Cookies) > 0
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SetSourceVariable 覆盖保存书源变量(前端变量编辑器)。
|
||||
// 保存后书源 JS 的 getVariable() 即可读到。
|
||||
func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variable string) error {
|
||||
if strings.TrimSpace(variable) != "" && !json.Valid([]byte(variable)) {
|
||||
return fmt.Errorf("变量必须是合法 JSON")
|
||||
}
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
sess.state.SetVariable(variable)
|
||||
sess.state.flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSourceLoginInfo 直接覆盖保存登录信息(前端表单保存,不触发登录动作)。
|
||||
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string, fields map[string]string) error {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
b, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess.state.SetLoginInfo(string(b))
|
||||
sess.state.flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSourceLogin 清除登录态:登录信息与全部 Cookie(对应 legado logout)。
|
||||
func (s *ReaderService) ClearSourceLogin(ctx context.Context, sourceID string) error {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
sess.state.SetLoginInfo("")
|
||||
sess.state.SetLoginHeader("")
|
||||
sess.state.clearCookies()
|
||||
sess.state.flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginBindings 构造登录动作的 JS 作用域绑定。
|
||||
// 对应 legado:result 为表单数据;book/chapter/isLongClick 一并提供。
|
||||
func loginBindings(sess *sourceSession, values map[string]string) map[string]any {
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
return map[string]any{
|
||||
"result": values,
|
||||
"book": nil,
|
||||
"chapter": nil,
|
||||
"isLongClick": false,
|
||||
"sourceUrl": sess.srcURL(),
|
||||
}
|
||||
}
|
||||
|
||||
func parseLoginInfoValues(raw string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isJSWrapped 判断规则串是否带 @js: / <js> 包裹。
|
||||
func isJSWrapped(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
return strings.HasPrefix(s, "@js:") || strings.HasPrefix(s, "<js>")
|
||||
}
|
||||
|
||||
func anyToStr(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 本文件:登录类书源的服务层链路测试。
|
||||
// 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。
|
||||
|
||||
// loginTestServer 模拟一个需要登录的书源站点:
|
||||
// - POST /login_api 校验账号密码并下发会话 Cookie
|
||||
// - GET /search 读取 Cookie,无 Cookie 返回 401(模拟登录后才能搜索)
|
||||
func loginTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/login_api":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["register_email"] != "user@example.com" || body["password"] != "pw123456" {
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "SESSION_abcdef123456", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"code":0,"key":"SESSION_abcdef123456"}`))
|
||||
case r.URL.Path == "/search":
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=SESSION_abcdef123456") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`<html><body>未登录</body></html>`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<html><body>
|
||||
<div class="item"><h3><a href="/book/9">会员专享书</a></h3><span class="author">作者</span></div>
|
||||
</body></html>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func loginTestSourceJSON(t *testing.T, server string) string {
|
||||
t.Helper()
|
||||
// loginUrl 是登录逻辑:读 result 里的表单值 → 调登录接口 → 写入 Cookie。
|
||||
// BaseUrl() 在真实书源里由 jsLib 提供,这里一并定义。
|
||||
loginJS := `function BaseUrl() { return baseUrl; }
|
||||
function login(flag) {
|
||||
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
|
||||
var res = java.ajax(BaseUrl() + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
|
||||
var data = JSON.parse(res);
|
||||
if (data.code == 0) {
|
||||
setAllCookies('qttoken=' + data.key);
|
||||
java.toast('登录成功');
|
||||
return true;
|
||||
}
|
||||
java.toast(data.msg || '登录失败');
|
||||
return false;
|
||||
}
|
||||
function setAllCookies(ck) { cookie.setCookie(BaseUrl(), ck); }`
|
||||
|
||||
loginUI := []map[string]any{
|
||||
{"name": "邮箱", "type": "text"},
|
||||
{"name": "密码", "type": "password"},
|
||||
{"name": "登录", "type": "button", "action": "login(true)"},
|
||||
}
|
||||
uiJSON, err := json.Marshal(loginUI)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": server,
|
||||
"bookSourceName": "登录源",
|
||||
"bookSourceType": 0,
|
||||
"enabledCookieJar": true,
|
||||
"loginUrl": loginJS,
|
||||
"loginUi": string(uiJSON),
|
||||
"searchUrl": server + "/search",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "class.item",
|
||||
"name": "tag.h3@tag.a@text",
|
||||
"bookUrl": "tag.h3@tag.a@href",
|
||||
"author": "class.author@text",
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func newLoginTestService(t *testing.T) (*ReaderService, *repository.Container) {
|
||||
t.Helper()
|
||||
// 唯一库名避免同包测试互相污染;cache=shared 让连接池共享同一份内存库
|
||||
// (多源搜索会并发写会话状态,而 :memory: 下每个连接各自一个库,
|
||||
// 并发写入对后续读取不可见,会造成测试假失败)。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
svc := NewReaderService(cfg, zap.NewNop(), repos)
|
||||
return svc, repos
|
||||
}
|
||||
|
||||
// prepareLoginSource 导入测试书源并返回其 ID。
|
||||
func prepareLoginSource(t *testing.T, svc *ReaderService, sourceJSON string) string {
|
||||
t.Helper()
|
||||
if _, err := svc.ImportSources(t.Context(), sourceJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcs, err := svc.ListSources(t.Context())
|
||||
if err != nil || len(srcs) == 0 {
|
||||
t.Fatalf("导入后应能读到书源: %v", err)
|
||||
}
|
||||
return srcs[0].ID
|
||||
}
|
||||
|
||||
// TestSourceLoginEndToEnd 登录成功后:
|
||||
// - Cookie 落库(可再次读出)
|
||||
// - 搜索请求自动携带 Cookie 并通过鉴权
|
||||
// - 登出后 Cookie 清除,搜索重新变成未登录
|
||||
func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, repos := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
}
|
||||
|
||||
// ── 登录 ──
|
||||
res, err := svc.RunLoginAction(ctx, sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "pw123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("登录失败: %v", err)
|
||||
}
|
||||
if !res.OK {
|
||||
t.Fatalf("登录未成功: %+v", res)
|
||||
}
|
||||
if !res.LoggedIn {
|
||||
t.Fatalf("登录后应处于已登录态: %+v", res)
|
||||
}
|
||||
if len(res.Cookies) == 0 {
|
||||
t.Fatalf("登录后应有 Cookie 落库: %+v", res)
|
||||
}
|
||||
|
||||
// ── 登录态应落库(换一个 service 实例仍可读到)──
|
||||
svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos)
|
||||
info, err := svc2.GetSourceLogin(ctx, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.LoggedIn || len(info.Cookies) == 0 {
|
||||
t.Fatalf("新实例未能读到已持久化的登录态: %+v", info)
|
||||
}
|
||||
if info.Values["邮箱"] != "user@example.com" {
|
||||
t.Fatalf("登录表单值未持久化: %+v", info.Values)
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("已登录后搜索不应失败: %+v", skipped)
|
||||
}
|
||||
if len(books) != 1 || books[0].Name != "会员专享书" {
|
||||
t.Fatalf("搜索结果异常: %+v", books)
|
||||
}
|
||||
|
||||
// ── 登出:Cookie 清除,搜索重新未登录 ──
|
||||
if err := svc.ClearSourceLogin(ctx, sourceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = svc.GetSourceLogin(ctx, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLoginWrongPassword 密码错误时登录动作应报失败并给出书源提示。
|
||||
func TestSourceLoginWrongPassword(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
res, err := svc.RunLoginAction(t.Context(), sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "wrong",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.LoggedIn {
|
||||
t.Fatalf("密码错误不应处于已登录态: %+v", res)
|
||||
}
|
||||
if len(res.Toasts) == 0 {
|
||||
t.Fatalf("应把书源的失败提示回传:%+v", res)
|
||||
}
|
||||
if !strings.Contains(strings.Join(res.Toasts, " "), "密码错误") {
|
||||
t.Fatalf("提示语未透传: %v", res.Toasts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLoginInfo_ExposesUIFields 登录界面描述应包含 loginUi 的控件定义。
|
||||
func TestSourceLoginInfo_ExposesUIFields(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
info, err := svc.GetSourceLogin(t.Context(), sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.HasLoginJS {
|
||||
t.Fatal("应识别到 loginUrl")
|
||||
}
|
||||
var names []string
|
||||
for _, f := range info.Fields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
for _, want := range []string{"邮箱", "密码", "登录"} {
|
||||
if !containsStr(names, want) {
|
||||
t.Fatalf("loginUi 字段缺失 %q: %v", want, names)
|
||||
}
|
||||
}
|
||||
// 密码字段类型应保留,前端据此用 password 输入框
|
||||
for _, f := range info.Fields {
|
||||
if f.Name == "密码" && f.Type != "password" {
|
||||
t.Fatalf("密码字段类型 = %q", f.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStateEncryptedAtRest 登录信息与 Cookie 应加密落库。
|
||||
func TestSourceStateEncryptedAtRest(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, repos := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
if _, err := svc.RunLoginAction(ctx, sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "pw123456",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, err := repos.Reader.GetSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := repos.Reader.GetSourceState(ctx, src.SourceURL)
|
||||
if err != nil || st == nil {
|
||||
t.Fatalf("未找到会话状态: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(st.LoginInfo, "enc:v1:") {
|
||||
t.Fatalf("登录信息应加密存储,实际: %q", st.LoginInfo)
|
||||
}
|
||||
if !strings.HasPrefix(st.Cookies, "enc:v1:") {
|
||||
t.Fatalf("Cookie 应加密存储,实际: %q", st.Cookies)
|
||||
}
|
||||
if strings.Contains(st.LoginInfo, "pw123456") {
|
||||
t.Fatal("明文密码出现在库中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceVariableRoundTrip 源变量可通过接口读写,并被书源 JS getVariable 读到。
|
||||
func TestSourceVariableRoundTrip(t *testing.T) {
|
||||
srv := loginTestServer(t)
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
if err := svc.SetSourceVariable(ctx, sourceID, `{"线路":"https://v2.example.com"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := svc.GetSourceLogin(ctx, sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(info.Variable), &m); err != nil {
|
||||
t.Fatalf("变量回读失败: %q", info.Variable)
|
||||
}
|
||||
if m["线路"] != "https://v2.example.com" {
|
||||
t.Fatalf("变量值不符: %+v", m)
|
||||
}
|
||||
|
||||
// 非法 JSON 应被拒绝
|
||||
if err := svc.SetSourceVariable(ctx, sourceID, "not-json"); err == nil {
|
||||
t.Fatal("非法 JSON 变量应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestEnabledCookieJarGating enabledCookieJar=false 时不再自动保存响应 Set-Cookie,
|
||||
// 但书源 JS 主动 cookie.setCookie 写入的仍应保留(对应 legado 语义)。
|
||||
func TestEnabledCookieJarGating(t *testing.T) {
|
||||
// 站点在响应里下发 Set-Cookie
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "auto", Value: "from-response", Path: "/"})
|
||||
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">书</a></h3></div></body></html>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 造一个 searchUrl 指向该站点、enabledCookieJar=false 的书源
|
||||
srcJSON := `{
|
||||
"bookSourceUrl": "` + srv.URL + `",
|
||||
"bookSourceName": "无 CookieJar 源",
|
||||
"bookSourceType": 0,
|
||||
"enabledCookieJar": false,
|
||||
"searchUrl": "` + srv.URL + `/search",
|
||||
"ruleSearch": {"bookList":"class.item","name":"tag.h3@tag.a@text","bookUrl":"tag.h3@tag.a@href"}
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
src, _ := reposReaderURL(ctx, svc, sourceID)
|
||||
st := svc.newSourceState(ctx, src)
|
||||
if got := st.GetCookie(srv.URL); strings.Contains(got, "auto=from-response") {
|
||||
t.Fatalf("enabledCookieJar=false 时不应自动保存 Set-Cookie: %q", got)
|
||||
}
|
||||
|
||||
// 对照:开启时应当捕获
|
||||
srcJSONOn := strings.Replace(srcJSON, `"enabledCookieJar": false`, `"enabledCookieJar": true`, 1)
|
||||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "auto", Value: "from-response", Path: "/"})
|
||||
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">书</a></h3></div></body></html>`))
|
||||
}))
|
||||
defer srv2.Close()
|
||||
srcJSONOn = strings.ReplaceAll(srcJSONOn, srv.URL, srv2.URL)
|
||||
// prepareLoginSource 返回列表首个书源,这里按 URL 精确定位刚导入的对照源
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
t.Fatalf("enabledCookieJar 默认开启时应捕获 Set-Cookie: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// reposReaderURL 取书源的 SourceURL(会话状态按它索引)。
|
||||
func reposReaderURL(ctx context.Context, svc *ReaderService, sourceID string) (string, error) {
|
||||
src, err := svc.repo.GetSource(ctx, sourceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return src.SourceURL, nil
|
||||
}
|
||||
@@ -37,19 +37,21 @@ const (
|
||||
|
||||
// ReaderService 阅读服务。
|
||||
type ReaderService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.ReaderRepository
|
||||
http *http.Client
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.ReaderRepository
|
||||
http *http.Client
|
||||
crypto *helper.SecretCipher
|
||||
}
|
||||
|
||||
// NewReaderService 创建服务。
|
||||
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
|
||||
return &ReaderService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repos.Reader,
|
||||
http: helper.NewSiteHTTPClient(30, true),
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repos.Reader,
|
||||
http: helper.NewSiteHTTPClient(30, true),
|
||||
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +61,8 @@ func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Con
|
||||
// 返回导入数量。
|
||||
func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
// 去 UTF-8 BOM(Windows 记事本导出的书源文件常见),否则 URL 检测和 JSON 解析都会失败
|
||||
text = strings.TrimPrefix(text, "\uFEFF")
|
||||
if text == "" {
|
||||
return 0, fmt.Errorf("导入内容为空")
|
||||
}
|
||||
@@ -136,7 +140,7 @@ func int64Now(p *int64) int64 {
|
||||
// ParseSourcePayload 识别 JSON 数组 / 单对象 / Base64 / 每行一个对象,
|
||||
// 返回书源 JSON 字符串列表(冒烟 CLI 复用)。
|
||||
func ParseSourcePayload(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.TrimPrefix(strings.TrimSpace(text), "\uFEFF")
|
||||
tryDecode := func(s string) []string {
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &arr); err == nil {
|
||||
@@ -190,9 +194,29 @@ func ParseSourcePayload(text string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ListSources 书源列表。
|
||||
// ListSources 书源列表(标注是否支持登录,供前端决定是否显示登录入口)。
|
||||
func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
|
||||
return s.repo.ListSources(ctx)
|
||||
sources, err := s.repo.ListSources(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range sources {
|
||||
sources[i].HasLogin = rawSourceHasLogin(sources[i].RawJSON)
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
// rawSourceHasLogin 只解出 loginUrl/loginUi 两个字段判断登录能力。
|
||||
// 列表接口按需计算,避免为了一个布尔值把每个书源的完整 JSON 都反序列化。
|
||||
func rawSourceHasLogin(rawJSON string) bool {
|
||||
var probe struct {
|
||||
LoginURL *string `json:"loginUrl"`
|
||||
LoginUI *string `json:"loginUi"`
|
||||
}
|
||||
if json.Unmarshal([]byte(rawJSON), &probe) != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(SPtr(probe.LoginURL)) != "" || strings.TrimSpace(SPtr(probe.LoginUI)) != ""
|
||||
}
|
||||
|
||||
// UpdateSourceEnabled 启停书源。
|
||||
@@ -214,6 +238,12 @@ func (s *ReaderService) DeleteSource(ctx context.Context, id string) error {
|
||||
|
||||
// execute 执行 rule.Request,返回(解码后 body, 最终 URL, HTTP 状态码)。
|
||||
func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string, string, int, error) {
|
||||
return s.executeWithState(ctx, req, nil, false)
|
||||
}
|
||||
|
||||
// executeWithState 在 execute 基础上叠加会话:附加 Cookie / loginHeader,
|
||||
// 并在 captureCookies 为真时把响应 Set-Cookie 回写到会话。
|
||||
func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request, state *sourceState, captureCookies bool) (string, string, int, error) {
|
||||
var bodyReader io.Reader
|
||||
if req.Body != "" {
|
||||
bodyReader = strings.NewReader(req.Body)
|
||||
@@ -222,6 +252,23 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
|
||||
if target == "" {
|
||||
target = req.URL
|
||||
}
|
||||
if state != nil {
|
||||
// 登录请求头(除 Cookie 外)优先级低于书源显式配置,高于预设。
|
||||
for k, v := range state.LoginHeaderMap() {
|
||||
if strings.EqualFold(k, "cookie") {
|
||||
continue
|
||||
}
|
||||
if _, ok := req.Headers[k]; !ok {
|
||||
req.Headers[k] = v
|
||||
}
|
||||
}
|
||||
// Cookie 仅在调用方未显式指定时附加,避免覆盖书源自带的鉴权 Cookie。
|
||||
if hasHeaderFold(req.Headers, "cookie") == "" {
|
||||
if ck := state.CookieForRequest(target); ck != "" {
|
||||
req.Headers["Cookie"] = ck
|
||||
}
|
||||
}
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
@@ -263,8 +310,10 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
|
||||
for _, ck := range resp.Cookies() {
|
||||
cookieStrs = append(cookieStrs, ck.Name+"="+ck.Value)
|
||||
}
|
||||
if len(cookieStrs) > 0 {
|
||||
rule.CookieJarRecord(finalURL, cookieStrs)
|
||||
if len(cookieStrs) > 0 && state != nil && captureCookies {
|
||||
for _, ck := range cookieStrs {
|
||||
state.SetCookie(finalURL, ck)
|
||||
}
|
||||
}
|
||||
// bodyJs 二次处理
|
||||
if req.BodyJsFn != nil {
|
||||
@@ -277,6 +326,16 @@ func (s *ReaderService) execute(ctx context.Context, req *rule.Request) (string,
|
||||
return body, finalURL, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// hasHeaderFold 大小写不敏感地取请求头值。
|
||||
func hasHeaderFold(headers map[string]string, name string) string {
|
||||
for k, v := range headers {
|
||||
if strings.EqualFold(k, name) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func charsetFromContentType(ct string) string {
|
||||
if ct == "" {
|
||||
return ""
|
||||
@@ -294,34 +353,160 @@ func charsetFromContentType(ct string) string {
|
||||
|
||||
// ─── 规则执行辅助 ───────────────────────────────────────────────────────────
|
||||
|
||||
// newRuleAnalyzer 为指定书源构建规则解析器(注入书源变量与 JS 运行时)。
|
||||
func (s *ReaderService) newRuleAnalyzer(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int, body, finalURL string) *rule.AnalyzeRule {
|
||||
// sourceSession 是一次书源操作的执行上下文:把书源记录、解析结构与
|
||||
// 会话状态(Cookie/变量/登录信息)绑在一起,供各阶段复用并在结束时落库。
|
||||
type sourceSession struct {
|
||||
svc *ReaderService
|
||||
ctx context.Context
|
||||
src *model.ReaderBookSource
|
||||
bs *BookSource
|
||||
state *sourceState
|
||||
}
|
||||
|
||||
// newSession 为指定书源建立执行上下文。
|
||||
func (s *ReaderService) newSession(ctx context.Context, src *model.ReaderBookSource, bs *BookSource) *sourceSession {
|
||||
url := ""
|
||||
if src != nil {
|
||||
url = src.SourceURL
|
||||
} else if bs != nil {
|
||||
url = bs.BookSourceURL
|
||||
}
|
||||
sess := &sourceSession{svc: s, ctx: ctx, src: src, bs: bs, state: s.newSourceState(ctx, url)}
|
||||
sess.seedVariable()
|
||||
return sess
|
||||
}
|
||||
|
||||
// seedVariable 用书源 JSON 的 variables 字段初始化源变量(仅当尚未保存过)。
|
||||
// 对应「导入书源后源变量为作者设定的默认值」的行为。
|
||||
func (sess *sourceSession) seedVariable() {
|
||||
if sess.bs == nil || sess.state == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(sess.state.GetVariable()) != "" {
|
||||
return
|
||||
}
|
||||
if raw := strings.TrimSpace(SPtr(sess.bs.RawVariables)); raw != "" && json.Valid([]byte(raw)) {
|
||||
sess.state.SetVariable(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// close 落库会话状态(Cookie/变量可能在执行中被书源 JS 改写)。
|
||||
func (sess *sourceSession) close() {
|
||||
if sess != nil && sess.state != nil {
|
||||
sess.state.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// fetch 执行请求,并按书源配置决定是否自动保存响应里的 Cookie。
|
||||
func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) {
|
||||
body, finalURL, code, err := sess.svc.executeWithState(sess.ctx, req, sess.state, sess.captureCookies())
|
||||
if err != nil {
|
||||
return body, finalURL, code, err
|
||||
}
|
||||
// loginCheckJs:书源借此检测会话失效并自行重登/重取(对应 legado WebBook.checkJs)。
|
||||
// 返回新 body 时替换原响应,使上层规则直接拿到修复后的内容。
|
||||
if check := SPtr(sess.bs.LoginCheckJS); strings.TrimSpace(check) != "" {
|
||||
body = sess.applyLoginCheck(check, body, code, finalURL)
|
||||
}
|
||||
return body, finalURL, code, nil
|
||||
}
|
||||
|
||||
// captureCookies 是否自动保存响应里的 Set-Cookie。
|
||||
// 对应 legado 的 enabledCookieJar(默认 true):关掉后不再自动累积 Cookie,
|
||||
// 但书源 JS 主动 cookie.setCookie 写入的仍会保存(那是明确意图)。
|
||||
func (sess *sourceSession) captureCookies() bool {
|
||||
return sess.bs == nil || sess.bs.EnabledCookieJarOrDefault()
|
||||
}
|
||||
|
||||
// applyLoginCheck 执行 loginCheckJs;失败时保留原 body,避免因检查脚本本身出错而中断阅读。
|
||||
func (sess *sourceSession) applyLoginCheck(check, body string, code int, finalURL string) string {
|
||||
runner := sess.runner("", 0)
|
||||
newBody, changed, err := runner.EvalLoginCheck(stripJSWrapper(check), body, code, finalURL)
|
||||
if err != nil {
|
||||
if sess.svc.log != nil {
|
||||
sess.svc.log.Warn("reader:loginCheckJs 执行失败",
|
||||
zap.String("source", sess.srcName()), zap.Error(err))
|
||||
}
|
||||
return body
|
||||
}
|
||||
if changed && newBody != "" {
|
||||
return newBody
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// newAnalyzer 构建规则解析器(注入书源变量与 JS 运行时)。
|
||||
func (sess *sourceSession) newAnalyzer(key string, page int, body, finalURL string) *rule.AnalyzeRule {
|
||||
ar := rule.NewAnalyzeRule()
|
||||
ar.SetContent(body, finalURL)
|
||||
applySourceVariables(ar, bs)
|
||||
ar.SetJSRunner(s.jsRunnerFor(ctx, src, bs, key, page).ForAnalyzer(ar))
|
||||
applySourceVariables(ar, sess.bs)
|
||||
// 内嵌 JS 里的 java.put/java.get 读写书源级变量(对应 source.variableMap)
|
||||
ar.SetSourceVariables(sess.state.GetVariableKey, sess.state.SetVariableKey)
|
||||
ar.SetJSRunner(sess.runner(key, page).ForAnalyzer(ar))
|
||||
return ar
|
||||
}
|
||||
|
||||
// jsRunnerFor 为本次请求构建 JS 运行时(网络桥回 execute,携带书源上下文)。
|
||||
func (s *ReaderService) jsRunnerFor(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int) *rule.JSRunner {
|
||||
// runner 构建本次请求的 JS 运行时(网络桥回 execute,携带书源上下文与会话状态)。
|
||||
func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
|
||||
return rule.NewJSRunner(rule.JSConfig{
|
||||
Fetch: func(req *rule.Request) (string, string, int, error) {
|
||||
return s.execute(ctx, req)
|
||||
return sess.fetch(req)
|
||||
},
|
||||
SourceProps: bs.SourceProps(),
|
||||
SourceProps: sess.bs.SourceProps(),
|
||||
Log: func(msg string) {
|
||||
if s.log != nil {
|
||||
s.log.Info("reader:source-js",
|
||||
zap.String("source", srcNameOf(src, bs)), zap.String("log", msg))
|
||||
if sess.svc.log != nil {
|
||||
sess.svc.log.Info("reader:source-js",
|
||||
zap.String("source", srcNameOf(sess.src, sess.bs)), zap.String("log", msg))
|
||||
}
|
||||
},
|
||||
BaseURL: src.SourceURL,
|
||||
BaseURL: sess.srcURL(),
|
||||
Key: key,
|
||||
Page: page,
|
||||
State: sess.state,
|
||||
JSLib: SPtr(sess.bs.JSLib),
|
||||
})
|
||||
}
|
||||
|
||||
// srcURL 书源标识 URL(会话状态与 baseUrl 的键)。
|
||||
func (sess *sourceSession) srcURL() string {
|
||||
if sess.src != nil && sess.src.SourceURL != "" {
|
||||
return sess.src.SourceURL
|
||||
}
|
||||
if sess.bs != nil {
|
||||
return sess.bs.BookSourceURL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// srcName 书源显示名。
|
||||
func (sess *sourceSession) srcName() string {
|
||||
return srcNameOf(sess.src, sess.bs)
|
||||
}
|
||||
|
||||
// headerJSON 书源级请求头 JSON。
|
||||
func (sess *sourceSession) headerJSON() string {
|
||||
if sess.src != nil && sess.src.Header != "" {
|
||||
return sess.src.Header
|
||||
}
|
||||
return SPtr(sess.bs.Header)
|
||||
}
|
||||
|
||||
// applyHeaders 把书源级请求头合并进请求(不覆盖已显式设置的值)。
|
||||
func (sess *sourceSession) applyHeaders(req *rule.Request) {
|
||||
raw := sess.headerJSON()
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
var headers map[string]any
|
||||
if json.Unmarshal([]byte(raw), &headers) == nil {
|
||||
for k, v := range headers {
|
||||
if _, ok := req.Headers[k]; !ok {
|
||||
req.Headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func srcNameOf(src *model.ReaderBookSource, bs *BookSource) string {
|
||||
if src != nil {
|
||||
return src.Name
|
||||
@@ -480,30 +665,22 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
|
||||
if sr == nil || SPtr(sr.BookList) == "" {
|
||||
return nil, fmt.Errorf("书源未配置搜索列表规则")
|
||||
}
|
||||
runner := s.jsRunnerFor(ctx, src, bs, key, page)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(searchURL, key, page, src.SourceURL, runner)
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
runner := sess.runner(key, page)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(searchURL, key, page, sess.srcURL(), runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
// 书源级请求头
|
||||
if src.Header != "" {
|
||||
var headers map[string]any
|
||||
if json.Unmarshal([]byte(src.Header), &headers) == nil {
|
||||
for k, v := range headers {
|
||||
if _, ok := req.Headers[k]; !ok {
|
||||
req.Headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
body, finalURL, _, err := s.execute(ctx, req)
|
||||
sess.applyHeaders(req)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ar := s.newRuleAnalyzer(ctx, src, bs, key, page, body, finalURL)
|
||||
ar := sess.newAnalyzer(key, page, body, finalURL)
|
||||
|
||||
elements, err := ar.GetElements(SPtr(sr.BookList))
|
||||
if err != nil {
|
||||
@@ -596,20 +773,22 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
|
||||
if bir == nil {
|
||||
return nil, fmt.Errorf("书源未配置详情规则")
|
||||
}
|
||||
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(bookURL, "", 0, src.SourceURL, runner)
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
runner := sess.runner("", 0)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(bookURL, "", 0, sess.srcURL(), runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
applySourceHeaders(req, src)
|
||||
body, finalURL, _, err := s.execute(ctx, req)
|
||||
sess.applyHeaders(req)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
|
||||
info := &BookInfo{BookURL: bookURL, TocURL: bookURL}
|
||||
if initRule := SPtr(bir.Init); initRule != "" {
|
||||
@@ -687,20 +866,22 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
|
||||
if tocURL == "" {
|
||||
tocURL = bookURL
|
||||
}
|
||||
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, src.SourceURL, runner)
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
runner := sess.runner("", 0)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(tocURL, "", 0, sess.srcURL(), runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
applySourceHeaders(req, src)
|
||||
body, finalURL, _, err := s.execute(ctx, req)
|
||||
sess.applyHeaders(req)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
|
||||
elements, err := ar.GetElements(SPtr(tr.ChapterList))
|
||||
if err != nil {
|
||||
@@ -790,6 +971,20 @@ func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook,
|
||||
}
|
||||
}
|
||||
}
|
||||
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
|
||||
if s.repo != nil {
|
||||
state := s.newSourceState(ctx, book.Origin)
|
||||
for k, v := range state.LoginHeaderMap() {
|
||||
if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
if httpReq.Header.Get("Cookie") == "" {
|
||||
if ck := state.CookieForRequest(rawURL); ck != "" {
|
||||
httpReq.Header.Set("Cookie", ck)
|
||||
}
|
||||
}
|
||||
}
|
||||
if httpReq.Header.Get("Referer") == "" && book.Origin != "" {
|
||||
httpReq.Header.Set("Referer", strings.TrimSuffix(book.Origin, "/")+"/")
|
||||
}
|
||||
@@ -854,22 +1049,25 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
||||
var parts []string
|
||||
url := chapterURL
|
||||
lastFinalURL := ""
|
||||
// 整章(含翻页)共用一个会话,翻页期间 Cookie/变量变更保持一致。
|
||||
sess := s.newSession(ctx, src, bs)
|
||||
defer sess.close()
|
||||
for i := 0; i < maxContentNextPage; i++ {
|
||||
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(url, "", 0, src.SourceURL, runner)
|
||||
runner := sess.runner("", 0)
|
||||
req, err := rule.ParseAnalyzeUrlWithJS(url, "", 0, sess.srcURL(), runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
applySourceHeaders(req, src)
|
||||
body, finalURL, _, err := s.execute(ctx, req)
|
||||
sess.applyHeaders(req)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastFinalURL = finalURL
|
||||
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
|
||||
list, err := ar.GetStringList(SPtr(cr.Content), nil, false)
|
||||
if err != nil {
|
||||
|
||||
@@ -231,6 +231,10 @@ func TestParseSourcePayload(t *testing.T) {
|
||||
if got := ParseSourcePayload(b64); len(got) != 1 {
|
||||
t.Fatalf("base64 payload = %d", len(got))
|
||||
}
|
||||
// UTF-8 BOM(Windows 导出文件常见)
|
||||
if got := ParseSourcePayload("\uFEFF" + arr); len(got) != 2 {
|
||||
t.Fatalf("bom array payload = %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSearchResults(t *testing.T) {
|
||||
|
||||
@@ -404,6 +404,48 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
|
||||
}
|
||||
|
||||
// ── 宿主交互:服务端无 UI,转为可回传前端的提示 / 待打开链接 ──
|
||||
if r.state != nil {
|
||||
toast := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
r.state.Toast(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Null()
|
||||
}
|
||||
set("toast", toast)
|
||||
set("longToast", toast)
|
||||
// startBrowser(url, title):记录待打开地址,前端可代开新标签。
|
||||
set("startBrowser", func(call goja.FunctionCall) goja.Value {
|
||||
r.state.OpenBrowser(stringArg(call, 0), stringArgOr(call, 1, ""))
|
||||
return goja.Null()
|
||||
})
|
||||
// startBrowserAwait:服务端无 WebView,无法等待人工校验。
|
||||
// 记录地址后抛出明确错误,避免书源逻辑误把空 body 当成功。
|
||||
set("startBrowserAwait", func(call goja.FunctionCall) goja.Value {
|
||||
url := stringArg(call, 0)
|
||||
r.state.OpenBrowser(url, stringArgOr(call, 1, ""))
|
||||
panic(vm.ToValue("java.startBrowserAwait: 服务端无浏览器,需要人工操作的页面请手动打开:" + url))
|
||||
})
|
||||
}
|
||||
// 设备标识:部分源用 deviceID/androidId 做"是否支持该环境"探测,
|
||||
// 成功返回会让源走安卓分支,这里统一以异常告知不支持并回退到通用分支。
|
||||
for _, name := range []string{"deviceID", "androidId"} {
|
||||
set(name, func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + ": 服务端无设备标识"))
|
||||
})
|
||||
}
|
||||
// 刷新发现页 / 打开界面:纯 UI 动作,服务端空实现。
|
||||
for _, name := range []string{"refreshExplore", "open", "showBrowser", "reLoginView", "qread"} {
|
||||
set(name, func(call goja.FunctionCall) goja.Value { return goja.Null() })
|
||||
}
|
||||
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持
|
||||
unsupported := func(name string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持"))
|
||||
@@ -411,7 +453,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
}
|
||||
for _, name := range []string{
|
||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||
"startBrowser", "startBrowserAwait", "openVideoPlayer", "getVerificationCode",
|
||||
"openVideoPlayer", "getVerificationCode",
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
|
||||
@@ -48,7 +48,8 @@ type JSFetcher func(req *Request) (body string, finalURL string, code int, err e
|
||||
// JSConfig 构造 JS 运行时的配置。
|
||||
type JSConfig struct {
|
||||
Fetch JSFetcher
|
||||
// SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样)。
|
||||
// SourceProps 注入为 JS 的 `source` 对象(书源 JSON 原样);
|
||||
// 同时供 source.getLoginInfoMap 从 loginUi 的 default 初始化登录信息。
|
||||
SourceProps map[string]any
|
||||
// Log 对应 java.log。
|
||||
Log func(msg string)
|
||||
@@ -59,32 +60,156 @@ type JSConfig struct {
|
||||
// Key / Page 搜索上下文绑定({{key}}/{{page}} 在 JS 里的取值)。
|
||||
Key string
|
||||
Page int
|
||||
// State 书源会话状态(变量/登录信息/Cookie)。nil 时用进程内 MemoryState。
|
||||
State SourceState
|
||||
// JSLib 书源 jsLib:在运行时创建后立即执行一次,
|
||||
// 其顶层函数与 lexical 绑定对该源后续所有 JS 可见(对应 legado SharedJsScope)。
|
||||
JSLib string
|
||||
// StateOnly 只构建会话状态与 jsLib 环境(登录交互用),
|
||||
// 不注入 book/result 等规则上下文。
|
||||
StateOnly bool
|
||||
}
|
||||
|
||||
// JSRunner 是一个单协程使用的 JS 运行时(每个 AnalyzeRule 一个实例)。
|
||||
type JSRunner struct {
|
||||
vm *goja.Runtime
|
||||
cfg JSConfig
|
||||
vars map[string]string // runner 级变量(URL 上下文 java.put/get)
|
||||
vm *goja.Runtime
|
||||
cfg JSConfig
|
||||
vars map[string]string // runner 级变量(URL 上下文 java.put/get)
|
||||
state SourceState
|
||||
// jsLibErr 记录 jsLib 执行失败原因(登录接口需要如实回报)。
|
||||
jsLibErr error
|
||||
}
|
||||
|
||||
// NewJSRunner 创建运行时并注入全局对象:cookie / cache / source。
|
||||
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = defaultJSTimeout
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}}
|
||||
vm.Set("cookie", newCookieObject(vm))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
if cfg.SourceProps != nil {
|
||||
vm.Set("source", cfg.SourceProps)
|
||||
} else {
|
||||
vm.Set("source", vm.NewObject())
|
||||
state := cfg.State
|
||||
if state == nil {
|
||||
state = NewMemoryState()
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
|
||||
vm.Set("cookie", newCookieObject(vm, state))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
|
||||
srcObj := newSourceObject(vm, state, cfg.SourceProps)
|
||||
vm.Set("source", srcObj)
|
||||
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
|
||||
r.installJava(nil)
|
||||
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
|
||||
r.loadJSLib()
|
||||
return r
|
||||
}
|
||||
|
||||
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
|
||||
func (r *JSRunner) installJava(a *AnalyzeRule) {
|
||||
r.vm.Set("java", newJavaObject(r.vm, r, a))
|
||||
}
|
||||
|
||||
// loadJSLib 执行书源 jsLib(一次),使其中定义的函数与 lexical 绑定
|
||||
// 对后续 Run 可见(goja 的顶层 let/const 与函数声明在同一 Runtime 内保持)。
|
||||
func (r *JSRunner) loadJSLib() {
|
||||
if strings.TrimSpace(r.cfg.JSLib) == "" {
|
||||
return
|
||||
}
|
||||
prog, err := compileCached(r.cfg.JSLib)
|
||||
if err != nil {
|
||||
r.jsLibErr = fmt.Errorf("jsLib 编译失败: %w", err)
|
||||
return
|
||||
}
|
||||
timer := time.AfterFunc(r.cfg.Timeout, func() { r.vm.Interrupt("jsLib 执行超时") })
|
||||
defer timer.Stop()
|
||||
if _, err := r.vm.RunProgram(prog); err != nil {
|
||||
r.jsLibErr = fmt.Errorf("jsLib 执行失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// JSLibErr 返回 jsLib 的执行错误(nil 表示正常)。
|
||||
func (r *JSRunner) JSLibErr() error { return r.jsLibErr }
|
||||
|
||||
// State 返回运行时使用的会话状态。
|
||||
func (r *JSRunner) State() SourceState { return r.state }
|
||||
|
||||
// EvalAction 在已装载 jsLib 的环境中执行一段交互 JS(登录按钮 action 等)。
|
||||
// bindings 为附加的 JS 全局绑定(如 result)。返回脚本的返回值。
|
||||
//
|
||||
// 绑定集合与 Run 对齐(baseUrl/key/page),因为登录交互 JS 同样会用到它们。
|
||||
func (r *JSRunner) EvalAction(js string, bindings map[string]any) (any, error) {
|
||||
if r.jsLibErr != nil {
|
||||
return nil, r.jsLibErr
|
||||
}
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
if r.cfg.Key != "" {
|
||||
vm.Set("key", r.cfg.Key)
|
||||
} else {
|
||||
vm.Set("key", nil)
|
||||
}
|
||||
if r.cfg.Page > 0 {
|
||||
vm.Set("page", r.cfg.Page)
|
||||
} else {
|
||||
vm.Set("page", nil)
|
||||
}
|
||||
for k, v := range bindings {
|
||||
vm.Set(k, toJSValue(vm, v))
|
||||
}
|
||||
prog, err := compileCached(js)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 编译失败: %w", err)
|
||||
}
|
||||
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("JS 执行超时") })
|
||||
defer timer.Stop()
|
||||
v, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 执行失败: %v", err)
|
||||
}
|
||||
return exportValue(v), nil
|
||||
}
|
||||
|
||||
// EvalLoginCheck 执行书源的 loginCheckJs(对应 legado WebBook 的 checkJs 钩子)。
|
||||
//
|
||||
// 语义对齐 legado:作用域里的 `result` 是一个响应对象(body()/code()/url()),
|
||||
// 脚本须返回响应对象或字符串;返回新 body 时调用方以之替换原响应体,
|
||||
// 从而支持「检测到会话失效 → 重新登录 → 重取页面」。
|
||||
func (r *JSRunner) EvalLoginCheck(js, body string, code int, finalURL string) (string, bool, error) {
|
||||
if r.jsLibErr != nil {
|
||||
return "", false, r.jsLibErr
|
||||
}
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
vm.Set("result", newResponseObject(vm, body, code, finalURL, nil))
|
||||
|
||||
prog, err := compileCached(js)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 编译失败: %w", err)
|
||||
}
|
||||
timer := time.AfterFunc(r.cfg.Timeout, func() { vm.Interrupt("loginCheckJs 执行超时") })
|
||||
defer timer.Stop()
|
||||
v, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
|
||||
}
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return "", false, nil
|
||||
}
|
||||
// 返回响应对象时取 body()(对应 legado 的 StrResponse)
|
||||
if obj, ok := v.(*goja.Object); ok {
|
||||
if fn, ok := goja.AssertFunction(obj.Get("body")); ok {
|
||||
res, err := fn(obj)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("loginCheckJs 执行失败: %v", err)
|
||||
}
|
||||
return res.String(), true, nil
|
||||
}
|
||||
}
|
||||
return v.String(), true, nil
|
||||
}
|
||||
|
||||
|
||||
// ForAnalyzer 返回绑定到指定解析器的执行函数(java.getString 等规则回调
|
||||
// 会桥回该解析器,对应 legado 中 AnalyzeRule 自身实现 JsExtensions)。
|
||||
func (r *JSRunner) ForAnalyzer(a *AnalyzeRule) func(js string, result any) (any, error) {
|
||||
@@ -104,7 +229,7 @@ func (r *JSRunner) RunWithBaseURL(a *AnalyzeRule, js string, result any, baseURL
|
||||
func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (any, error) {
|
||||
vm := r.vm
|
||||
// java 对象:每次执行重建(桥回当前解析器)
|
||||
vm.Set("java", newJavaObject(vm, r, a))
|
||||
r.installJava(a)
|
||||
// 上下文绑定
|
||||
if a != nil {
|
||||
vm.Set("book", map[string]any{"name": a.bookName})
|
||||
@@ -176,6 +301,13 @@ func toJSValue(vm *goja.Runtime, v any) any {
|
||||
return nil
|
||||
case string, bool, int, int32, int64, float64, map[string]any, []any:
|
||||
return t
|
||||
// 登录表单等以 map[string]string 传入,交给 goja 直接反射转换
|
||||
case map[string]string:
|
||||
m := make(map[string]any, len(t))
|
||||
for k, v := range t {
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
case []string:
|
||||
arr := make([]any, len(t))
|
||||
for i, s := range t {
|
||||
|
||||
@@ -3,8 +3,8 @@ package rule
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -61,88 +61,240 @@ func hexEncodeBytes(b []byte) string { return hex.EncodeToString(b) }
|
||||
|
||||
// ─── cookie 对象(对应 legado CookieStore 注入的 `cookie`) ─────────────────
|
||||
|
||||
var cookieJar = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]map[string]string // host → name → value
|
||||
}{m: map[string]map[string]string{}}
|
||||
// 会话状态由服务层注入(落库);未注入时用进程内 MemoryState。
|
||||
// 见 state.go —— 旧的全局 cookieJar 因无法按书源隔离且不落库已废弃。
|
||||
|
||||
// CookieJarRecord 供服务层在执行 HTTP 请求时记录 Set-Cookie。
|
||||
func CookieJarRecord(rawURL string, cookies []string) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
host := u.Host
|
||||
cookieJar.mu.Lock()
|
||||
defer cookieJar.mu.Unlock()
|
||||
jar, ok := cookieJar.m[host]
|
||||
if !ok {
|
||||
jar = map[string]string{}
|
||||
cookieJar.m[host] = jar
|
||||
}
|
||||
for _, c := range cookies {
|
||||
pair := strings.SplitN(c, ";", 2)[0]
|
||||
kv := strings.SplitN(pair, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(kv[0])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
jar[name] = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
|
||||
// CookieJarHeader 供书源 JS 查询(对应 CookieStore.getCookie(tag[, key]))。
|
||||
func CookieJarHeader(rawURL, key string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
cookieJar.mu.Lock()
|
||||
defer cookieJar.mu.Unlock()
|
||||
jar, ok := cookieJar.m[u.Host]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if key != "" {
|
||||
return jar[key]
|
||||
}
|
||||
var parts []string
|
||||
for k, v := range jar {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func newCookieObject(vm *goja.Runtime) *goja.Object {
|
||||
// newCookieObject 构造 JS 的 `cookie` 对象:
|
||||
// getCookie(url[,key]) / setCookie(url,cookie) / replaceCookie / removeCookie / getKey。
|
||||
func newCookieObject(vm *goja.Runtime, state SourceState) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
// getCookie(key) 或 getCookie(url, key)
|
||||
set("getCookie", func(call goja.FunctionCall) goja.Value {
|
||||
tag := stringArg(call, 0)
|
||||
key := ""
|
||||
if len(call.Arguments) > 1 {
|
||||
key = stringArg(call, 1)
|
||||
return vm.ToValue(state.GetCookieKey(tag, stringArg(call, 1)))
|
||||
}
|
||||
return vm.ToValue(CookieJarHeader(tag, key))
|
||||
return vm.ToValue(state.GetCookie(tag))
|
||||
})
|
||||
set("setCookie", func(call goja.FunctionCall) goja.Value {
|
||||
rawURL := stringArg(call, 0)
|
||||
cookie := stringArgOr(call, 1, "")
|
||||
if cookie != "" {
|
||||
CookieJarRecord(rawURL, []string{cookie})
|
||||
state.SetCookie(rawURL, cookie)
|
||||
}
|
||||
return vm.ToValue(cookie)
|
||||
})
|
||||
// replaceCookie 与 setCookie 在服务端实现中同为覆盖式合并(对应 legado 的语义)
|
||||
set("replaceCookie", func(call goja.FunctionCall) goja.Value {
|
||||
rawURL := stringArg(call, 0)
|
||||
cookie := stringArgOr(call, 1, "")
|
||||
if cookie != "" {
|
||||
state.SetCookie(rawURL, cookie)
|
||||
}
|
||||
return vm.ToValue(cookie)
|
||||
})
|
||||
set("removeCookie", func(call goja.FunctionCall) goja.Value {
|
||||
state.RemoveCookie(stringArg(call, 0))
|
||||
return goja.Null()
|
||||
})
|
||||
set("getKey", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(state.GetCookieKey(stringArg(call, 0), stringArg(call, 1)))
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
// newSourceObject 构造 JS 的 `source` 对象。
|
||||
// 对应 legado BaseSource 的变量与登录信息读写。
|
||||
func newSourceObject(vm *goja.Runtime, state SourceState, props map[string]any) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
bindSourceState(vm, set, state, props)
|
||||
// source.get/put:书源级键值缓存(legado 中即 source 的方法)。
|
||||
// 注意 java.get/put 另有语义(网络 / 解析器变量),在 bridge.go 中定义,
|
||||
// 不在这里覆盖。
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(sourceKeyGet(state, stringArg(call, 0)))
|
||||
})
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
val := stringArgOr(call, 1, "")
|
||||
sourceKeyPut(state, stringArg(call, 0), val)
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
// 书源自身属性(bookSourceUrl 等)原样可读,存量 JS 会读 source.loginUi 等做能力探测。
|
||||
for k, v := range props {
|
||||
if o.Get(k) != nil {
|
||||
continue
|
||||
}
|
||||
_ = o.Set(k, v)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// bindSourceState 把书源会话状态方法绑定到目标 JS 对象。
|
||||
// legado 中 book source 的 evalJS 把 `java` 绑成书源对象自身,因此 `java` 与
|
||||
// `source` 都能读到 getVariable/getLoginInfo 等方法——这里保持一致。
|
||||
func bindSourceState(vm *goja.Runtime, set func(k string, v any), state SourceState, props map[string]any) {
|
||||
set("getVariable", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(state.GetVariable())
|
||||
})
|
||||
setVariable := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
state.SetVariable("")
|
||||
return goja.Null()
|
||||
}
|
||||
state.SetVariable(call.Arguments[0].String())
|
||||
return goja.Null()
|
||||
}
|
||||
set("setVariable", setVariable)
|
||||
set("putVariable", setVariable)
|
||||
|
||||
set("getLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
if v := state.GetLoginInfo(); v != "" {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("putLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
v := ""
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) && !goja.IsNull(call.Arguments[0]) {
|
||||
v = call.Arguments[0].String()
|
||||
}
|
||||
state.SetLoginInfo(v)
|
||||
return vm.ToValue(true)
|
||||
})
|
||||
set("removeLoginInfo", func(call goja.FunctionCall) goja.Value {
|
||||
state.SetLoginInfo("")
|
||||
return goja.Null()
|
||||
})
|
||||
// getLoginInfoMap 对应 Kotlin:解析登录信息 JSON;
|
||||
// 未设置且 loginUi 非空时,用 loginUi 里各字段的 default 初始化。
|
||||
set("getLoginInfoMap", func(call goja.FunctionCall) goja.Value {
|
||||
if m := parseLoginInfoMap(state.GetLoginInfo()); m != nil {
|
||||
return vm.ToValue(m)
|
||||
}
|
||||
return vm.ToValue(initLoginInfoFromUI(props))
|
||||
})
|
||||
|
||||
set("getLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
if v := state.GetLoginHeader(); v != "" {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("putLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
header := stringArgOr(call, 0, "")
|
||||
state.SetLoginHeader(header)
|
||||
// 请求头里的 Cookie 同步进 cookie 存储(对应 legado putLoginHeader)
|
||||
if header != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(header), &m) == nil {
|
||||
for k, v := range m {
|
||||
if strings.EqualFold(k, "cookie") {
|
||||
state.SetCookie(headerCookieURL(props), fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
set("removeLoginHeader", func(call goja.FunctionCall) goja.Value {
|
||||
state.SetLoginHeader("")
|
||||
state.RemoveCookie(headerCookieURL(props))
|
||||
return goja.Null()
|
||||
})
|
||||
set("getKey", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(headerCookieURL(props))
|
||||
})
|
||||
}
|
||||
|
||||
// sourceKeyGet / sourceKeyPut:source.get/put 的独立键值槽,
|
||||
// 以 "__kv__" 前缀编码进同一份变量 JSON,从而与 getVariable 一起持久化。
|
||||
const sourceKVVar = "__kv__"
|
||||
|
||||
func sourceKeyGet(state SourceState, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]string
|
||||
_ = json.Unmarshal([]byte(state.GetVariable()), &m)
|
||||
return m[sourceKVVar+key]
|
||||
}
|
||||
|
||||
func sourceKeyPut(state SourceState, key, val string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
m := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(state.GetVariable()), &m)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
m[sourceKVVar+key] = val
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
state.SetVariable(string(b))
|
||||
}
|
||||
|
||||
// headerCookieURL 取 loginHeader 里 Cookie 归属的 URL。
|
||||
func headerCookieURL(props map[string]any) string {
|
||||
if props != nil {
|
||||
if u, ok := props["bookSourceUrl"].(string); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseLoginInfoMap 解析登录信息 JSON;空串或非法 JSON 返回 nil。
|
||||
func parseLoginInfoMap(s string) map[string]string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(s), &raw) != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
out[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// initLoginInfoFromUI 用 loginUi 各字段 default 生成初始登录信息
|
||||
// (对应 legado getLoginInfoMap:跳过 button 类型)。
|
||||
func initLoginInfoFromUI(props map[string]any) map[string]string {
|
||||
out := map[string]string{}
|
||||
raw, _ := props["loginUi"].(string)
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
var rows []map[string]any
|
||||
if json.Unmarshal([]byte(raw), &rows) == nil {
|
||||
for _, row := range rows {
|
||||
if t, _ := row["type"].(string); t == "button" {
|
||||
continue
|
||||
}
|
||||
name, _ := row["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
def, _ := row["default"].(string)
|
||||
out[name] = def
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ──────────────────
|
||||
|
||||
var jsCache = struct {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件用「聚合类登录书源」的典型结构验证服务端 JS 运行时:
|
||||
//
|
||||
// jsLib(公共函数库)+ loginUrl(登录逻辑)+ loginUi(表单)
|
||||
//
|
||||
// 与真实书源(如光遇聚合,jsLib 约 15 万字符)同构,但精简为可维护的固件。
|
||||
// 固件放在 testdata/,因此这些是常驻回归测试而非一次性 spike。
|
||||
|
||||
func loadTestdata(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("读取固件 %s 失败: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// sampleSourceProps 构造注入了 jsLib/loginUrl/loginUi 的书源属性。
|
||||
func sampleSourceProps(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
return map[string]any{
|
||||
"bookSourceUrl": "https://v1.example-aggregate.com",
|
||||
"bookSourceName": "样例聚合源",
|
||||
"jsLib": loadTestdata(t, "sample_jsLib.js"),
|
||||
"loginUrl": loadTestdata(t, "sample_loginUrl.js"),
|
||||
"loginUi": loadTestdata(t, "sample_loginUi.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// newSampleRunner 用聚合源固件构建运行时,网络由测试桩接管。
|
||||
func newSampleRunner(t *testing.T, state SourceState) *JSRunner {
|
||||
t.Helper()
|
||||
props := sampleSourceProps(t)
|
||||
r := NewJSRunner(JSConfig{
|
||||
SourceProps: props,
|
||||
JSLib: props["jsLib"].(string),
|
||||
State: state,
|
||||
BaseURL: "https://v1.example-aggregate.com",
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
if strings.Contains(req.URL, "/login_api") {
|
||||
return `{"code":0,"key":"TOKEN_abcdefghijklmn"}`, req.URL, 200, nil
|
||||
}
|
||||
if strings.Contains(req.URL, "/user_api") {
|
||||
return `{"id":1,"email":"user@example.com","nickname":"tester"}`, req.URL, 200, nil
|
||||
}
|
||||
return `{}`, req.URL, 200, nil
|
||||
},
|
||||
})
|
||||
if err := r.JSLibErr(); err != nil {
|
||||
t.Fatalf("jsLib 执行失败: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// TestJSLibProvidesHelpers jsLib 里的函数与 lexical 绑定(let hosts)应跨执行可见。
|
||||
func TestJSLibProvidesHelpers(t *testing.T) {
|
||||
r := newSampleRunner(t, NewMemoryState())
|
||||
v, err := r.EvalAction(`typeof getVariable + '|' + typeof request + '|' + typeof hosts`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalAction 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "function|function|object" {
|
||||
t.Fatalf("jsLib 符号不可见: %q", got)
|
||||
}
|
||||
// BaseUrl() 依赖 lexical hosts 与 source.getVariable
|
||||
v, err = r.EvalAction(`BaseUrl()`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BaseUrl 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "https://v1.example-aggregate.com" {
|
||||
t.Fatalf("BaseUrl() = %q", got)
|
||||
}
|
||||
// login 由 loginUrl 提供,未拼 loginUrl 时不应存在
|
||||
v, _ = r.EvalAction(`typeof login`, nil)
|
||||
if anyToString(v) != "undefined" {
|
||||
t.Fatalf("login 应仅由 loginUrl 定义,当前 %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginFlowWritesCookies login() 应请求登录接口、写入 Cookie,且 getToken 能读回。
|
||||
func TestLoginFlowWritesCookies(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
_, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "user@example.com", "密码": "pw123456"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("login(true) 执行失败: %v", err)
|
||||
}
|
||||
v, err := r.EvalAction(`getToken()`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getToken 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "TOKEN_abcdefghijklmn" {
|
||||
t.Fatalf("getToken() = %q,期望登录后能读到 token", got)
|
||||
}
|
||||
// setAllCookies 遍历 hosts;两条线路同属 example-aggregate.com,
|
||||
// 按 eTLD+1 归并成一条 Cookie,两个线路地址都应能读到。
|
||||
if len(state.Snapshot()) != 1 {
|
||||
t.Fatalf("同站线路应归并为一条 Cookie,实际 %v", state.Snapshot())
|
||||
}
|
||||
for _, host := range []string{"https://v1.example-aggregate.com", "https://v2.example-aggregate.com"} {
|
||||
if !strings.Contains(state.GetCookie(host), "qttoken=TOKEN_abcdefghijklmn") {
|
||||
t.Fatalf("%s 未读到 token: %q", host, state.GetCookie(host))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "登录成功") {
|
||||
t.Fatalf("未收到登录成功提示: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginMissingCredentials 缺少账号密码时应给出提示且不产生 Cookie。
|
||||
func TestLoginMissingCredentials(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
if _, err := r.EvalAction(loginJS+"\nlogin(true)", map[string]any{"result": map[string]string{}}); err != nil {
|
||||
t.Fatalf("执行失败: %v", err)
|
||||
}
|
||||
if len(state.Snapshot()) != 0 {
|
||||
t.Fatalf("未填表单不应写入 Cookie: %v", state.Snapshot())
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "请先输入账号密码") {
|
||||
t.Fatalf("缺少提示语: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartBrowserAwaitRecordsURL startBrowserAwait 应记录待打开地址并明确报错,
|
||||
// 避免书源逻辑把空 body 当成校验成功。
|
||||
func TestStartBrowserAwaitRecordsURL(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
// 先造出登录态,让 user() 走到 startBrowserAwait
|
||||
state.SetCookie("https://v1.example-aggregate.com", "qttoken=TOKEN_abcdefghijklmn")
|
||||
_, err := r.EvalAction(loginJS+"\nuser()", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "无浏览器") {
|
||||
t.Fatalf("应明确报不支持,实际: %v", err)
|
||||
}
|
||||
browsers := state.Browsers()
|
||||
if len(browsers) != 1 || !strings.HasSuffix(browsers[0].URL, "/user") {
|
||||
t.Fatalf("未记录待打开地址: %+v", browsers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceLoginInfoMapInit 未保存登录信息时,getLoginInfoMap 用 loginUi 的
|
||||
// 非按钮字段初始化(对应 legado)。
|
||||
func TestSourceLoginInfoMapInit(t *testing.T) {
|
||||
r := newSampleRunner(t, NewMemoryState())
|
||||
v, err := r.EvalAction(`JSON.stringify(source.getLoginInfoMap())`, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getLoginInfoMap 失败: %v", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(anyToString(v)), &m); err != nil {
|
||||
t.Fatalf("返回不是 JSON 对象: %q", anyToString(v))
|
||||
}
|
||||
if _, ok := m["邮箱"]; !ok {
|
||||
t.Fatalf("loginUi 的「邮箱」未出现在登录信息: %v", m)
|
||||
}
|
||||
if _, ok := m["密码"]; !ok {
|
||||
t.Fatalf("loginUi 的「密码」未出现在登录信息: %v", m)
|
||||
}
|
||||
for k := range m {
|
||||
if strings.Contains(k, "登录") || strings.Contains(k, "注册") || strings.Contains(k, "后台") {
|
||||
t.Fatalf("button 字段不应出现在登录信息: %v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceVariablesRoundTrip 变量经 source.setVariable 写入后可由 getVariable 读出。
|
||||
func TestSourceVariablesRoundTrip(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := newSampleRunner(t, state)
|
||||
|
||||
if _, err := r.EvalAction(`setVariable('线路','https://v2.example-aggregate.com',false)`, nil); err != nil {
|
||||
t.Fatalf("setVariable 失败: %v", err)
|
||||
}
|
||||
v, err := r.EvalAction(`getVariable('线路')`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "https://v2.example-aggregate.com" {
|
||||
t.Fatalf("getVariable('线路') = %q", got)
|
||||
}
|
||||
// 变量变更应反映到状态存储(持久化的前提)
|
||||
if !strings.Contains(state.GetVariable(), "v2.example-aggregate.com") {
|
||||
t.Fatalf("变量未写回状态存储: %q", state.GetVariable())
|
||||
}
|
||||
// BaseUrl 也应跟随变量变化
|
||||
v, _ = r.EvalAction(`BaseUrl()`, nil)
|
||||
if anyToString(v) != "https://v2.example-aggregate.com" {
|
||||
t.Fatalf("BaseUrl 未跟随变量: %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookieObjectIsolation Cookie 按站点隔离,同站子域共享,removeCookie 可清除。
|
||||
func TestCookieObjectIsolation(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
r := NewJSRunner(JSConfig{State: state})
|
||||
|
||||
if _, err := r.EvalAction(`cookie.setCookie('https://a.example.com/x','t=1; u=2')`, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := r.EvalAction(`cookie.getCookie('https://a.example.com/y','t')`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "1" {
|
||||
t.Fatalf("getCookie key = %q", anyToString(v))
|
||||
}
|
||||
// 同站子域共享 Cookie(对应 legado getSubDomain 取 eTLD+1)
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://www.example.com/')`, nil)
|
||||
if anyToString(v) == "" {
|
||||
t.Fatal("同站子域应共享 Cookie")
|
||||
}
|
||||
// 不同站点互不污染
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://other-site.net/')`, nil)
|
||||
if anyToString(v) != "" {
|
||||
t.Fatalf("跨站点读到了 Cookie: %q", anyToString(v))
|
||||
}
|
||||
// removeCookie 后读不到
|
||||
if _, err := r.EvalAction(`cookie.removeCookie('https://a.example.com')`, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, _ = r.EvalAction(`cookie.getCookie('https://a.example.com/')`, nil)
|
||||
if anyToString(v) != "" {
|
||||
t.Fatalf("removeCookie 后仍读到: %q", anyToString(v))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatePersistsAcrossRunners 状态存储是跨运行时共享的:
|
||||
// 模拟服务端重启(新建 runner)后登录态仍在。
|
||||
func TestSourceStatePersistsAcrossRunners(t *testing.T) {
|
||||
state := NewMemoryState()
|
||||
loginJS := loadTestdata(t, "sample_loginUrl.js")
|
||||
|
||||
r1 := newSampleRunner(t, state)
|
||||
if _, err := r1.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 新运行时复用同一 state(对应从 DB 重新载入)
|
||||
r2 := newSampleRunner(t, state)
|
||||
v, err := r2.EvalAction(`getToken()`, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(v) != "TOKEN_abcdefghijklmn" {
|
||||
t.Fatalf("新运行时未读到登录态: %q", anyToString(v))
|
||||
}
|
||||
// 已登录时再次 login 应提示已登录(走 getToken 短路分支)
|
||||
if _, err := r2.EvalAction(loginJS+"\nlogin(true)", map[string]any{
|
||||
"result": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(state.Toasts(), "\n"), "已登录") {
|
||||
t.Fatalf("已登录时应有提示: %v", state.Toasts())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsReplacesBody loginCheckJs 返回新响应体时应替换原 body
|
||||
// (书源借此检测会话失效并重取页面)。
|
||||
func TestLoginCheckJsReplacesBody(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
// 返回响应对象(对应 legado 要求 loginCheckJs 返回 StrResponse)
|
||||
js := `if (result.body().indexOf('未登录') >= 0) { java.toast('会话失效,重新登录'); }
|
||||
var ok = JSON.stringify({code: 0, body: result.body()});
|
||||
result.body()`
|
||||
body, changed, err := r.EvalLoginCheck(js, "未登录", 200, "https://x.com/a")
|
||||
if err != nil {
|
||||
t.Fatalf("EvalLoginCheck 失败: %v", err)
|
||||
}
|
||||
if !changed || body != "未登录" {
|
||||
t.Fatalf("body=%q changed=%v", body, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsReturnsString loginCheckJs 直接返回字符串时也应生效。
|
||||
func TestLoginCheckJsReturnsString(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
body, changed, err := r.EvalLoginCheck(`'已修复内容'`, "原始", 200, "https://x.com/a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || body != "已修复内容" {
|
||||
t.Fatalf("body=%q changed=%v", body, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCheckJsResponseAccessors 验证 result 暴露 code()/url()/header()。
|
||||
func TestLoginCheckJsResponseAccessors(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{State: NewMemoryState()})
|
||||
body, changed, err := r.EvalLoginCheck(
|
||||
`result.code() + '|' + result.url()`, "x", 403, "https://x.com/y")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed || body != "403|https://x.com/y" {
|
||||
t.Fatalf("body=%q", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// 本文件:书源会话状态(变量 / 登录信息 / 登录请求头 / Cookie)的抽象。
|
||||
// 对应 legado BaseSource 的 getVariable/putVariable/getLoginInfo/putLoginInfo/
|
||||
// getLoginHeader/putLoginHeader 与 CookieStore。实现由服务层落库提供;
|
||||
// 未注入时退回进程内 MemoryState(单测、冒烟 CLI 用)。
|
||||
|
||||
// SourceState 书源会话状态读写接口。
|
||||
type SourceState interface {
|
||||
// GetVariable / SetVariable 对应 source.getVariable / source.setVariable。
|
||||
GetVariable() string
|
||||
SetVariable(v string)
|
||||
|
||||
// GetLoginInfo / SetLoginInfo 对应 source.getLoginInfo / source.putLoginInfo。
|
||||
// 内容为登录表单的 JSON(键为字段名)。
|
||||
GetLoginInfo() string
|
||||
SetLoginInfo(v string)
|
||||
|
||||
// GetLoginHeader / SetLoginHeader 对应 source 的登录请求头(JSON),
|
||||
// 其中 Cookie 键会在请求时合并进 Cookie 头。
|
||||
GetLoginHeader() string
|
||||
SetLoginHeader(v string)
|
||||
|
||||
// Cookie 读写:rawURL 可为完整 URL 或裸域名,按有效顶级域+1 归并。
|
||||
GetCookie(rawURL string) string
|
||||
GetCookieKey(rawURL, key string) string
|
||||
SetCookie(rawURL, cookie string)
|
||||
RemoveCookie(rawURL string)
|
||||
|
||||
// Toast 收集宿主提示(java.toast / java.longToast),登录反馈靠它回传前端。
|
||||
Toast(msg string)
|
||||
// OpenBrowser 记录需要浏览器完成的地址(java.startBrowser)——
|
||||
// 服务端无法弹窗,前端据此提供「在新标签打开」。
|
||||
OpenBrowser(url, title string)
|
||||
}
|
||||
|
||||
// CookieDomain 取 URL 的有效顶级域 +1(对应 legado NetworkUtils.getSubDomain)。
|
||||
// 裸域名(无 scheme)按 http 处理;IP 原样返回;解析失败回退 host 本身。
|
||||
func CookieDomain(rawURL string) string {
|
||||
s := strings.TrimSpace(rawURL)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(s, "://") {
|
||||
if strings.HasPrefix(s, "//") {
|
||||
s = "http:" + s
|
||||
} else {
|
||||
s = "http://" + s
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if net.ParseIP(host) != nil {
|
||||
return host
|
||||
}
|
||||
etld1, err := publicsuffix.EffectiveTLDPlusOne(host)
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
return etld1
|
||||
}
|
||||
|
||||
// ParseCookie 把 "a=1; b=2" 拆为键值对(值不 Trim 内部空白,仅去首尾)。
|
||||
func ParseCookie(cookie string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, part := range strings.Split(cookie, ";") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 || kv[0] == "" {
|
||||
continue
|
||||
}
|
||||
out[kv[0]] = kv[1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeCookie 将新 cookie 覆盖式合并进旧 cookie(对应 CookieStore.replaceCookie)。
|
||||
func MergeCookie(old, newCookie string) string {
|
||||
merged := ParseCookie(old)
|
||||
for k, v := range ParseCookie(newCookie) {
|
||||
merged[k] = v
|
||||
}
|
||||
return formatCookie(merged)
|
||||
}
|
||||
|
||||
func formatCookie(kv map[string]string) string {
|
||||
parts := make([]string, 0, len(kv))
|
||||
for k, v := range kv {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// MemoryState 进程内会话状态(未注入持久化实现时的回退)。
|
||||
type MemoryState struct {
|
||||
mu sync.Mutex
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]map[string]string // domain → name → value
|
||||
toasts []string
|
||||
browsers []BrowserRequest
|
||||
}
|
||||
|
||||
// BrowserRequest 前端可代为打开的浏览器地址(java.startBrowser 收集)。
|
||||
type BrowserRequest struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// NewMemoryState 创建进程内状态。
|
||||
func NewMemoryState() *MemoryState {
|
||||
return &MemoryState{cookies: map[string]map[string]string{}}
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetVariable() string { m.mu.Lock(); defer m.mu.Unlock(); return m.variable }
|
||||
func (m *MemoryState) SetVariable(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.variable = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetLoginInfo() string { m.mu.Lock(); defer m.mu.Unlock(); return m.loginInfo }
|
||||
func (m *MemoryState) SetLoginInfo(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.loginInfo = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetLoginHeader() string { m.mu.Lock(); defer m.mu.Unlock(); return m.loginHeader }
|
||||
func (m *MemoryState) SetLoginHeader(v string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.loginHeader = v
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetCookie(rawURL string) string {
|
||||
return m.GetCookieKey(rawURL, "")
|
||||
}
|
||||
|
||||
func (m *MemoryState) GetCookieKey(rawURL, key string) string {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
jar, ok := m.cookies[domain]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if key != "" {
|
||||
return jar[key]
|
||||
}
|
||||
return formatCookie(jar)
|
||||
}
|
||||
|
||||
func (m *MemoryState) SetCookie(rawURL, cookie string) {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
jar, ok := m.cookies[domain]
|
||||
if !ok {
|
||||
jar = map[string]string{}
|
||||
m.cookies[domain] = jar
|
||||
}
|
||||
for k, v := range ParseCookie(cookie) {
|
||||
jar[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryState) RemoveCookie(rawURL string) {
|
||||
domain := CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.cookies, domain)
|
||||
}
|
||||
|
||||
func (m *MemoryState) Toast(msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.toasts = append(m.toasts, msg)
|
||||
}
|
||||
|
||||
func (m *MemoryState) OpenBrowser(url, title string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.browsers = append(m.browsers, BrowserRequest{URL: url, Title: title})
|
||||
}
|
||||
|
||||
// Toasts 返回并清空已收集的宿主提示。
|
||||
func (m *MemoryState) Toasts() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := m.toasts
|
||||
m.toasts = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Browsers 返回并清空已收集的浏览器地址。
|
||||
func (m *MemoryState) Browsers() []BrowserRequest {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := m.browsers
|
||||
m.browsers = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// Snapshot 返回当前 Cookie(domain → cookie 串)副本。
|
||||
func (m *MemoryState) Snapshot() map[string]string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make(map[string]string, len(m.cookies))
|
||||
for d, jar := range m.cookies {
|
||||
out[d] = formatCookie(jar)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var _ SourceState = (*MemoryState)(nil)
|
||||
@@ -0,0 +1,117 @@
|
||||
// 测试用 jsLib:模拟聚合类书源的公共函数库结构。
|
||||
//
|
||||
// 真实书源(如光遇聚合)的 jsLib 有十几万字符,但关键结构就是这些:
|
||||
// 用 source.getVariable 做设置读写、用 cookie 存取凭证、用 BaseUrl() 拼接口地址。
|
||||
// 该文件由 JSRunner 在创建时执行一次,其函数对后续所有规则 JS 可见。
|
||||
// 同时保留了源码里常见的 lexical 声明(let hosts),验证跨执行可见性。
|
||||
|
||||
// 当前书源版本号
|
||||
let localVersion = '26.9.29.1';
|
||||
|
||||
// 初始服务器列表
|
||||
let hosts = [
|
||||
'https://v1.example-aggregate.com',
|
||||
'https://v2.example-aggregate.com'
|
||||
];
|
||||
|
||||
// 源变量默认值
|
||||
const defaultConfig = {
|
||||
线路: hosts[0],
|
||||
发现页来源: '番茄'
|
||||
};
|
||||
|
||||
// 获取源变量
|
||||
function getVariable(k) {
|
||||
if (k == undefined) k = "";
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(source.getVariable());
|
||||
} catch (e) {}
|
||||
if (k == "") {
|
||||
return parsed;
|
||||
}
|
||||
let value = parsed[k];
|
||||
if (value == undefined) {
|
||||
value = defaultConfig[k];
|
||||
}
|
||||
return value != undefined ? value : "";
|
||||
}
|
||||
|
||||
// 设置源变量
|
||||
function setVariable(k, v, t) {
|
||||
if (t == undefined) t = true;
|
||||
const vs = getVariable();
|
||||
vs[k] = v;
|
||||
source.setVariable(JSON.stringify(vs, null, 4));
|
||||
if (k != '云端配置' && t) {
|
||||
java.toast('设置 ' + k + ' 为 ' + v);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取正在使用的线路
|
||||
function BaseUrl() {
|
||||
let h = getVariable("线路");
|
||||
if (!h || String(h) == "undefined") {
|
||||
h = hosts[0];
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// 获取登陆 token
|
||||
function getToken() {
|
||||
let hostsbk = getVariable('云端配置') && getVariable('云端配置')['hosts'] || hosts;
|
||||
for (let i = 0; i < hostsbk.length; i++) {
|
||||
let cookieValue = String(cookie.getCookie(hostsbk[i]));
|
||||
let parts = cookieValue.split(";");
|
||||
for (let j = 0; j < parts.length; j++) {
|
||||
if (parts[j].indexOf("qttoken") != -1) {
|
||||
return parts[j].split("=")[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// 设置 ck
|
||||
function setAllCookies(ck) {
|
||||
let hostsbk = hosts;
|
||||
for (let i = 0; i < hostsbk.length; i++) {
|
||||
cookie.setCookie(hostsbk[i], ck);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 ck
|
||||
function removeAllCookies() {
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
cookie.removeCookie(hosts[i]);
|
||||
}
|
||||
java.toast('已退出登陆');
|
||||
}
|
||||
|
||||
// 请求封装(自动带 token)
|
||||
function request(url, method, body) {
|
||||
if (method == undefined) method = 'GET';
|
||||
if (body == undefined) body = {};
|
||||
let urla = url;
|
||||
if (url.indexOf('http') != 0) {
|
||||
urla = BaseUrl() + url;
|
||||
}
|
||||
let qttoken = getToken();
|
||||
let options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'cookie': 'qttoken=' + qttoken,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
};
|
||||
return java.ajax(urla + ',' + JSON.stringify(options));
|
||||
}
|
||||
|
||||
function parseJsonSafely(str) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
{
|
||||
"name": "线路设置",
|
||||
"type": "button",
|
||||
"action": "switchLine()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "邮箱",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "密码",
|
||||
"type": "password"
|
||||
},
|
||||
{
|
||||
"name": "🔅登录账号",
|
||||
"type": "button",
|
||||
"action": "login(true)",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🔐注册账号",
|
||||
"type": "button",
|
||||
"action": "register()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": " 🔚 退出登录",
|
||||
"type": "button",
|
||||
"action": "logout()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🪪 查看信息",
|
||||
"type": "button",
|
||||
"action": "checkStatus()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
},
|
||||
{
|
||||
"name": "🏝用户后台",
|
||||
"type": "button",
|
||||
"action": "user()",
|
||||
"style": { "layout_flexGrow": 1, "layout_flexBasisPercent": 0.4 }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
// 测试用 loginUrl:模拟聚合类书源的登录逻辑。
|
||||
//
|
||||
// 约定与 legado 一致:
|
||||
// - loginUrl 本身是一段 JS,既是登录逻辑,也是 loginUi 各按钮 action 的函数库;
|
||||
// - 表单值通过作用域里的 result 对象读取;
|
||||
// - 成功与否靠抛异常区分(返回值被忽略)。
|
||||
|
||||
function login(flag) {
|
||||
let token = getToken();
|
||||
if (String(token).length > 10) {
|
||||
java.longToast('当前已登录,请退出登录后重新登录');
|
||||
return true;
|
||||
}
|
||||
let email = result.邮箱;
|
||||
let pwd = result.密码;
|
||||
if (!email || !pwd) {
|
||||
java.longToast('请先输入账号密码!');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let data = request('/login_api', 'POST', {
|
||||
register_email: email,
|
||||
password: pwd
|
||||
});
|
||||
let response = parseJsonSafely(data);
|
||||
if (response && response.code == 0) {
|
||||
setAllCookies('qttoken=' + response.key);
|
||||
java.longToast('✅️登录成功');
|
||||
return true;
|
||||
}
|
||||
java.longToast('❌登录失败:' + ((response && response.msg) || '未知错误'));
|
||||
return false;
|
||||
} catch (e) {
|
||||
java.longToast('❌登录失败,服务器错误');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登陆
|
||||
function logout() {
|
||||
removeAllCookies();
|
||||
}
|
||||
|
||||
// 用户后台(需要浏览器打开)
|
||||
function user() {
|
||||
if (String(getToken()).length < 10) {
|
||||
java.longToast('请先登陆');
|
||||
return;
|
||||
}
|
||||
java.startBrowserAwait(BaseUrl() + '/user', '用户后台');
|
||||
}
|
||||
|
||||
// 查看信息
|
||||
function checkStatus() {
|
||||
if (String(getToken()).length < 10) {
|
||||
java.longToast('请先登陆');
|
||||
return;
|
||||
}
|
||||
let res = parseJsonSafely(request('/user_api', 'POST'));
|
||||
if (!res || res.id == undefined) {
|
||||
java.toast('获取用户信息失败');
|
||||
return;
|
||||
}
|
||||
result.邮箱 = res.email;
|
||||
source.putLoginInfo(JSON.stringify(result));
|
||||
java.longToast('昵称:' + (res.nickname || '未设置'));
|
||||
}
|
||||
@@ -22,6 +22,9 @@ type BookSource struct {
|
||||
ConcurrentRate *string `json:"concurrentRate"`
|
||||
Header *string `json:"header"`
|
||||
LoginURL *string `json:"loginUrl"`
|
||||
LoginUI *string `json:"loginUi"`
|
||||
LoginCheckJS *string `json:"loginCheckJs"`
|
||||
JSLib *string `json:"jsLib"`
|
||||
BookSourceComment *string `json:"bookSourceComment"`
|
||||
LastUpdateTime *int64 `json:"lastUpdateTime"`
|
||||
RespondTime *int64 `json:"respondTime"`
|
||||
@@ -38,6 +41,38 @@ type BookSource struct {
|
||||
RawVariables *string `json:"variables"`
|
||||
}
|
||||
|
||||
// EnabledCookieJarOrDefault 是否启用 Cookie 自动携带(legado 默认 true)。
|
||||
func (b *BookSource) EnabledCookieJarOrDefault() bool {
|
||||
return b.EnabledCookieJar == nil || *b.EnabledCookieJar
|
||||
}
|
||||
|
||||
// HasLogin 是否声明了登录能力(登录需要 loginUrl 的 JS 或 loginUi 表单)。
|
||||
func (b *BookSource) HasLogin() bool {
|
||||
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
|
||||
}
|
||||
|
||||
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
|
||||
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
||||
// 因此 loginUrl 同时充当登录交互的函数库。
|
||||
func (b *BookSource) LoginJS() string {
|
||||
return stripJSWrapper(SPtr(b.LoginURL))
|
||||
}
|
||||
|
||||
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
func stripJSWrapper(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "@js:") {
|
||||
return s[len("@js:"):]
|
||||
}
|
||||
if strings.HasPrefix(s, "<js>") {
|
||||
s = s[len("<js>"):]
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "</js>")
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "<")
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SearchRule 搜索规则。
|
||||
type SearchRule struct {
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:把书源会话状态(变量 / 登录信息 / 登录请求头 / Cookie)落到 DB。
|
||||
// 对应 legado 的 CacheManager + CookieStore,按书源 URL 隔离,
|
||||
// 使登录态在服务端重启后依然有效。
|
||||
|
||||
// sourceState 实现 rule.SourceState,按书源 URL 读写 ReaderSourceState。
|
||||
// 写入是"读-改-写":一次性把四个字段整体落库,保证并发下不丢字段。
|
||||
type sourceState struct {
|
||||
svc *ReaderService
|
||||
ctx context.Context
|
||||
sourceURL string
|
||||
// 进程内累积区:一次规则执行内可能多次读写,先落内存再统一 flush。
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]string // domain → "k=v; k=v"
|
||||
loaded bool
|
||||
|
||||
toasts []string
|
||||
browsers []rule.BrowserRequest
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// newSourceState 载入指定书源的会话状态(含解密)。
|
||||
func (s *ReaderService) newSourceState(ctx context.Context, sourceURL string) *sourceState {
|
||||
st := &sourceState{svc: s, ctx: ctx, sourceURL: sourceURL, cookies: map[string]string{}}
|
||||
if s.repo == nil || sourceURL == "" {
|
||||
st.loaded = true
|
||||
return st
|
||||
}
|
||||
rec, err := s.repo.GetSourceState(ctx, sourceURL)
|
||||
if err != nil || rec == nil {
|
||||
st.loaded = true
|
||||
return st
|
||||
}
|
||||
st.variable = s.decrypt(rec.Variable)
|
||||
st.loginInfo = s.decrypt(rec.LoginInfo)
|
||||
st.loginHeader = s.decrypt(rec.LoginHeader)
|
||||
if ck := s.decrypt(rec.Cookies); ck != "" {
|
||||
_ = json.Unmarshal([]byte(ck), &st.cookies)
|
||||
}
|
||||
st.loaded = true
|
||||
return st
|
||||
}
|
||||
|
||||
func (st *sourceState) GetVariable() string { return st.variable }
|
||||
|
||||
func (st *sourceState) SetVariable(v string) {
|
||||
if st.variable == v {
|
||||
return
|
||||
}
|
||||
st.variable = v
|
||||
st.dirty = true
|
||||
}
|
||||
|
||||
// GetVariableKey / SetVariableKey 以键为单位读写源变量 map,
|
||||
// 对应 legado source.variableMap(规则里的 @get:{} / @put:{} 与 java.get/put 走这里)。
|
||||
func (st *sourceState) GetVariableKey(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]string
|
||||
if json.Unmarshal([]byte(st.variable), &m) != nil {
|
||||
return ""
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
func (st *sourceState) SetVariableKey(key, value string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
m := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(st.variable), &m)
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
m[key] = value
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
st.SetVariable(string(b))
|
||||
}
|
||||
|
||||
func (st *sourceState) GetLoginInfo() string { return st.loginInfo }
|
||||
|
||||
func (st *sourceState) SetLoginInfo(v string) {
|
||||
if st.loginInfo == v {
|
||||
return
|
||||
}
|
||||
st.loginInfo = v
|
||||
st.dirty = true
|
||||
}
|
||||
|
||||
func (st *sourceState) GetLoginHeader() string { return st.loginHeader }
|
||||
|
||||
func (st *sourceState) SetLoginHeader(v string) {
|
||||
if st.loginHeader == v {
|
||||
return
|
||||
}
|
||||
st.loginHeader = v
|
||||
st.dirty = true
|
||||
}
|
||||
|
||||
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
|
||||
|
||||
func (st *sourceState) GetCookieKey(rawURL, key string) string {
|
||||
domain := rule.CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
}
|
||||
raw, ok := st.cookies[domain]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if key == "" {
|
||||
return raw
|
||||
}
|
||||
return rule.ParseCookie(raw)[key]
|
||||
}
|
||||
|
||||
func (st *sourceState) SetCookie(rawURL, cookie string) {
|
||||
domain := rule.CookieDomain(rawURL)
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
merged := rule.MergeCookie(st.cookies[domain], cookie)
|
||||
if merged == st.cookies[domain] {
|
||||
return
|
||||
}
|
||||
st.cookies[domain] = merged
|
||||
st.dirty = true
|
||||
}
|
||||
|
||||
func (st *sourceState) RemoveCookie(rawURL string) {
|
||||
domain := rule.CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := st.cookies[domain]; !ok {
|
||||
return
|
||||
}
|
||||
delete(st.cookies, domain)
|
||||
st.dirty = true
|
||||
}
|
||||
|
||||
func (st *sourceState) Toast(msg string) { st.toasts = append(st.toasts, msg) }
|
||||
|
||||
func (st *sourceState) OpenBrowser(url, title string) {
|
||||
st.browsers = append(st.browsers, rule.BrowserRequest{URL: url, Title: title})
|
||||
}
|
||||
|
||||
// flush 把累积状态落库(登录信息与 Cookie 加密存储)。
|
||||
func (st *sourceState) flush() {
|
||||
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
|
||||
return
|
||||
}
|
||||
cookiesJSON := ""
|
||||
if len(st.cookies) > 0 {
|
||||
if b, err := json.Marshal(st.cookies); err == nil {
|
||||
cookiesJSON = string(b)
|
||||
}
|
||||
}
|
||||
rec := &model.ReaderSourceState{
|
||||
SourceURL: st.sourceURL,
|
||||
Variable: st.variable,
|
||||
LoginInfo: st.svc.encrypt(st.loginInfo),
|
||||
LoginHeader: st.svc.encrypt(st.loginHeader),
|
||||
Cookies: st.svc.encrypt(cookiesJSON),
|
||||
}
|
||||
if err := st.svc.repo.SaveSourceState(st.ctx, rec); err != nil {
|
||||
if st.svc.log != nil {
|
||||
st.svc.log.Warn("reader: 保存书源会话状态失败: " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
st.dirty = false
|
||||
}
|
||||
|
||||
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
|
||||
func (st *sourceState) snapshotCookies() map[string]string {
|
||||
out := make(map[string]string, len(st.cookies))
|
||||
for d, c := range st.cookies {
|
||||
out[d] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
|
||||
func (st *sourceState) clearCookies() {
|
||||
if len(st.cookies) == 0 {
|
||||
return
|
||||
}
|
||||
st.cookies = map[string]string{}
|
||||
st.dirty = true
|
||||
}
|
||||
|
||||
// CookieForRequest 返回应附加到该请求的 Cookie 串:
|
||||
// 会话 Cookie 优先,其次是 loginHeader 中显式声明的 Cookie。
|
||||
func (st *sourceState) CookieForRequest(rawURL string) string {
|
||||
if st == nil {
|
||||
return ""
|
||||
}
|
||||
cookie := st.GetCookie(rawURL)
|
||||
if h := strings.TrimSpace(st.loginHeader); h != "" {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(h), &m) == nil {
|
||||
for k, v := range m {
|
||||
if strings.EqualFold(k, "cookie") {
|
||||
extra := strings.TrimSpace(toStringVal(v))
|
||||
if extra != "" {
|
||||
cookie = rule.MergeCookie(cookie, extra)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
// LoginHeaderMap 返回 loginHeader 的解析结果(除 Cookie 外的头)。
|
||||
func (st *sourceState) LoginHeaderMap() map[string]string {
|
||||
out := map[string]string{}
|
||||
if st == nil || strings.TrimSpace(st.loginHeader) == "" {
|
||||
return out
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(st.loginHeader), &m) != nil {
|
||||
return out
|
||||
}
|
||||
for k, v := range m {
|
||||
out[k] = toStringVal(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toStringVal(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// encrypt / decrypt 复用全局 CryptoService(密钥来自 JWTSecret)。
|
||||
func (s *ReaderService) encrypt(plain string) string {
|
||||
if s.crypto == nil {
|
||||
return plain
|
||||
}
|
||||
return s.crypto.Encrypt(plain)
|
||||
}
|
||||
|
||||
func (s *ReaderService) decrypt(value string) string {
|
||||
if s.crypto == nil {
|
||||
return value
|
||||
}
|
||||
return s.crypto.Decrypt(value)
|
||||
}
|
||||
|
||||
var _ rule.SourceState = (*sourceState)(nil)
|
||||
Reference in New Issue
Block a user