Files
MeBox/internal/service/reader/login.go
T
truewhile c1ff55ffbf 开发
2026-09-30 19:22:24 +08:00

292 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}