mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
292 lines
9.8 KiB
Go
292 lines
9.8 KiB
Go
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)
|
||
}
|