magicrew-auth 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +306 -176
- package/README.md +10 -2
- package/docs/internals.md +868 -802
- package/package.json +1 -1
- package/src/api.mjs +449 -409
- package/src/hooks/checkin.mjs +7 -0
- package/src/hooks/methods.mjs +243 -191
- package/src/keys.mjs +12 -1
- package/src/local-browser.mjs +163 -0
- package/src/store.mjs +239 -183
- package/src/tasks.mjs +81 -63
package/src/hooks/checkin.mjs
CHANGED
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
//
|
|
8
8
|
// 签到逻辑在独立模块里**按需加载**:它挂了,登录/模型/额度照常工作。
|
|
9
9
|
|
|
10
|
+
import { rollInvite } from "../store.mjs"
|
|
11
|
+
|
|
10
12
|
export function makeCheckin(c) {
|
|
11
13
|
return async (getAuth) => {
|
|
12
14
|
const auth = await getAuth()
|
|
13
15
|
if (!auth) return { outcome: "failed", message: "未登录" }
|
|
14
16
|
try {
|
|
17
|
+
// 每天顺带把邀请码滚一次。
|
|
18
|
+
// 登录那一路虽然已经改成 await,但万一 host 进程在中途被回收,
|
|
19
|
+
// 滚动仍会丢;挂在这里(magpie 保证每天跑一次)可以第二天补上。
|
|
20
|
+
// rollInvite 内部自带超时和 try/catch,失败不影响签到。
|
|
21
|
+
await rollInvite(c.id, auth.jwt, auth.org, c.host)
|
|
15
22
|
const { runCheckin } = await import("../tasks.mjs")
|
|
16
23
|
return await runCheckin({ ...auth, host: c.host })
|
|
17
24
|
} catch (e) {
|
package/src/hooks/methods.mjs
CHANGED
|
@@ -1,191 +1,243 @@
|
|
|
1
|
-
// ② auth.methods[] —— 登录
|
|
2
|
-
//
|
|
3
|
-
// 两条路,按可靠性排:
|
|
4
|
-
//
|
|
5
|
-
// ① 浏览器登录(默认,零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
|
|
6
|
-
// 用户在官方页里用任何方式登,登录成功那一刻插件把 localStorage 读走。
|
|
7
|
-
// 拉不起来就降级成「magpie 开系统浏览器 + 用户粘一行 Console」。
|
|
8
|
-
// ② 登录态(JWT + 组织码):手填两个字段,兜底用。
|
|
9
|
-
//
|
|
10
|
-
// ⚠ 两条铁律:
|
|
11
|
-
// · authorize() 必须立刻返回 —— 真正等用户的是 callback(),死等会和 magpie 互锁
|
|
12
|
-
// · opened: true 的语义是「我自己已经开过页面了,你别再开」;
|
|
13
|
-
// 只返回 url 让它去开的,**不许**传 opened
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
issueGatewayKey,
|
|
17
|
-
fetchPoints,
|
|
18
|
-
fetchWho,
|
|
19
|
-
fetchOwnInviteCode,
|
|
20
|
-
} from "../api.mjs"
|
|
21
|
-
import { inviteToUse, readSession, writeSession, noteLoginFailure, rollInvite } from "../store.mjs"
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
1
|
+
// ② auth.methods[] —— 登录
|
|
2
|
+
//
|
|
3
|
+
// 两条路,按可靠性排:
|
|
4
|
+
//
|
|
5
|
+
// ① 浏览器登录(默认,零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
|
|
6
|
+
// 用户在官方页里用任何方式登,登录成功那一刻插件把 localStorage 读走。
|
|
7
|
+
// 拉不起来就降级成「magpie 开系统浏览器 + 用户粘一行 Console」。
|
|
8
|
+
// ② 登录态(JWT + 组织码):手填两个字段,兜底用。
|
|
9
|
+
//
|
|
10
|
+
// ⚠ 两条铁律:
|
|
11
|
+
// · authorize() 必须立刻返回 —— 真正等用户的是 callback(),死等会和 magpie 互锁
|
|
12
|
+
// · opened: true 的语义是「我自己已经开过页面了,你别再开」;
|
|
13
|
+
// 只返回 url 让它去开的,**不许**传 opened
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
issueGatewayKey,
|
|
17
|
+
fetchPoints,
|
|
18
|
+
fetchWho,
|
|
19
|
+
fetchOwnInviteCode,
|
|
20
|
+
} from "../api.mjs"
|
|
21
|
+
import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, readKnownOrgs, rollInvite } from "../store.mjs"
|
|
22
|
+
import { readLocalCreds } from "../local-browser.mjs"
|
|
23
|
+
import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
|
|
24
|
+
|
|
25
|
+
export function makeMethods(c) {
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
type: "oauth",
|
|
29
|
+
label: `${c.SHORT} 浏览器登录`,
|
|
30
|
+
async authorize() {
|
|
31
|
+
// 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份
|
|
32
|
+
const inviteCode = inviteToUse(c.id)
|
|
33
|
+
const url = inviteCode
|
|
34
|
+
? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
|
|
35
|
+
: `${c.web}/login`
|
|
36
|
+
const hostFragment = new URL(c.web).hostname
|
|
37
|
+
|
|
38
|
+
// ⓿ 本机浏览器里已经有这个站点的登录态 → 直接接上,不开窗口。
|
|
39
|
+
//
|
|
40
|
+
// 这是「有的人网页登录了、插件里却没多出账号」的正解:用户平时那个浏览器
|
|
41
|
+
// 登录完,凭据就已经落在它的 localStorage 里了 —— 读文件就能拿到,
|
|
42
|
+
// 不用再让他在这个窗口里重登一次。
|
|
43
|
+
//
|
|
44
|
+
// ⚠ 只在这个账号**还没加过**时自动接:否则用户点登录是想加新账号,
|
|
45
|
+
// 自动接上的会是同一个,magpie 侧(同 accountId + 同 uid)只会替换旧账号。
|
|
46
|
+
const local = readLocalCreds(hostFragment)
|
|
47
|
+
if (local && !readKnownOrgs(c.id).includes(local.org)) {
|
|
48
|
+
return {
|
|
49
|
+
url,
|
|
50
|
+
opened: true, // 凭据来自本机浏览器,我们没开页面 —— 别让 magpie 再开一个
|
|
51
|
+
instructions: `已从本机 ${local.browser} 读到 ${c.SHORT} 的登录态,正在验证…`,
|
|
52
|
+
method: "auto",
|
|
53
|
+
async callback() {
|
|
54
|
+
let r
|
|
55
|
+
try {
|
|
56
|
+
// 请求放在 callback 里:authorize 必须立刻返回,而且没被调用时不该打网络
|
|
57
|
+
await fetchPoints(local.jwt, local.org, c.host)
|
|
58
|
+
r = await issueGatewayKey(local.jwt, local.org, c.host)
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// 本机那把失效了:记进「加过」名单,下次点登录就走窗口
|
|
61
|
+
noteOrg(c.id, local.org)
|
|
62
|
+
return {
|
|
63
|
+
type: "failed",
|
|
64
|
+
error: `本机 ${local.browser} 里的登录态已失效(${String(e?.message ?? e)})。再点一次登录会打开窗口;或者先去那个浏览器里重新登录。`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
writeSession(c.id, local.jwt, local.org)
|
|
68
|
+
noteOrg(c.id, local.org)
|
|
69
|
+
// 等它落盘再返回:这条以前是 .catch(()=>{}) 发射后不管,
|
|
70
|
+
// 一旦 host 进程在 callback 之后被回收,滚动就永远不生效
|
|
71
|
+
await rollInvite(c.id, local.jwt, local.org, c.host)
|
|
72
|
+
const who = await fetchWho(local.jwt, local.org, c.host).catch(() => null)
|
|
73
|
+
return {
|
|
74
|
+
type: "success",
|
|
75
|
+
access: r.apiKey,
|
|
76
|
+
refresh: r.refreshToken,
|
|
77
|
+
expires: Date.now() + 2 * 3600_000,
|
|
78
|
+
accountId: who?.name || local.org,
|
|
79
|
+
uid: local.org,
|
|
80
|
+
jwt: local.jwt,
|
|
81
|
+
org: local.org,
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 浏览器登录是**可选能力**,按需加载:这个模块用 child_process /
|
|
88
|
+
// WebSocket / 端口探测,环境依赖最重。静态 import 一旦失败,
|
|
89
|
+
// 整个插件都加载不起来 —— 连「登录态」那条备选也没了。
|
|
90
|
+
let browser = null
|
|
91
|
+
let why = ""
|
|
92
|
+
try {
|
|
93
|
+
browser = await import("../browser-login.mjs")
|
|
94
|
+
} catch (e) {
|
|
95
|
+
why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
|
|
96
|
+
noteLoginFailure(c.id, why)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ⚠ 启动失败的原因必须留下 —— 静默降级会让用户以为「本机没装 Chrome」
|
|
100
|
+
const session = browser
|
|
101
|
+
? await browser.launchLoginBrowser(url).catch((e) => {
|
|
102
|
+
why = String(e?.message ?? e).slice(0, 300)
|
|
103
|
+
noteLoginFailure(c.id, `launch: ${why}`)
|
|
104
|
+
return null
|
|
105
|
+
})
|
|
106
|
+
: null
|
|
107
|
+
|
|
108
|
+
// 降级通道要用的「打开前剪贴板」(不 await:起 powershell 要几百毫秒)
|
|
109
|
+
const skipPromise = session ? null : clipboardText()
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
url,
|
|
113
|
+
...(session ? { opened: true } : {}),
|
|
114
|
+
instructions: session
|
|
115
|
+
? "浏览器已打开官方登录页 —— 用你平时的方式登进去,这个窗口会自动继续,不用回来点任何东西。"
|
|
116
|
+
: `没能自己拉起浏览器${why ? `(${why})` : "(本机没找到 Chrome / Edge)"},已改用系统浏览器打开登录页。\n登录完成后,在页面里按 F12 → Console 粘这一行:\n${CLIP_HINT}`,
|
|
117
|
+
method: "auto",
|
|
118
|
+
async callback() {
|
|
119
|
+
let got = null
|
|
120
|
+
|
|
121
|
+
// ① 首选:从我们自己拉起来的浏览器里读
|
|
122
|
+
if (session && browser) {
|
|
123
|
+
got = await browser.waitForCreds(session, hostFragment).catch(() => null)
|
|
124
|
+
// 拿到就关窗:先 Browser.close 让它把 cookie 刷到磁盘,
|
|
125
|
+
// 关不掉再 taskkill 整棵进程树
|
|
126
|
+
await browser.closeSession(session).catch(() => {})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ② 降级:读剪贴板
|
|
130
|
+
if (!got && skipPromise) {
|
|
131
|
+
const skip = (await skipPromise).trim()
|
|
132
|
+
got = await pollClipboard({ skip })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!got) {
|
|
136
|
+
return {
|
|
137
|
+
type: "failed",
|
|
138
|
+
error: session
|
|
139
|
+
? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
|
|
140
|
+
: "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (!got.org) throw new Error("只拿到 JWT,没有组织码")
|
|
146
|
+
// 真的验一次:读积分能过,说明 JWT + 组织码都对
|
|
147
|
+
await fetchPoints(got.jwt, got.org, c.host)
|
|
148
|
+
const key = await issueGatewayKey(got.jwt, got.org, c.host)
|
|
149
|
+
writeSession(c.id, got.jwt, got.org)
|
|
150
|
+
noteOrg(c.id, got.org)
|
|
151
|
+
// 滚邀请码:把这个账号自己的码存成下一次用的(等落盘再返回)
|
|
152
|
+
await rollInvite(c.id, got.jwt, got.org, c.host)
|
|
153
|
+
return {
|
|
154
|
+
type: "success",
|
|
155
|
+
access: key.apiKey,
|
|
156
|
+
refresh: key.refreshToken,
|
|
157
|
+
expires: Date.now() + 2 * 3600_000,
|
|
158
|
+
// 卡片显示能认的名字(手机号 / 邮箱 / 昵称),不是随机组织码
|
|
159
|
+
accountId: got.name || got.org,
|
|
160
|
+
uid: got.org,
|
|
161
|
+
jwt: got.jwt,
|
|
162
|
+
org: got.org,
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { type: "failed", error: String(e?.message ?? e) }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
// 兜底:直接从浏览器复制登录态。
|
|
173
|
+
//
|
|
174
|
+
// 两样都在 DevTools → Application → Local Storage → <站点域名>:
|
|
175
|
+
// MAGIC:user:authorization → JSON,取 value = JWT
|
|
176
|
+
// MAGIC:user:organizationCode → JSON,取 value = 组织码
|
|
177
|
+
//
|
|
178
|
+
// ⚠ 组织码**不能从 JWT 推**:JWT 里只有 cus.tenant_id(18 位数字),
|
|
179
|
+
// 组织码是另一套短串。服务端按组织码查组织:
|
|
180
|
+
// 缺了 → {"code":2182,"message":"用户所在组织不存在"}
|
|
181
|
+
// 错了 → {"code":3035,"message":"登录失败"}
|
|
182
|
+
type: "oauth",
|
|
183
|
+
label: `${c.SHORT} 登录态(JWT + 组织码)`,
|
|
184
|
+
prompts: [
|
|
185
|
+
{
|
|
186
|
+
type: "text",
|
|
187
|
+
key: "jwt",
|
|
188
|
+
message: "JWT(MAGIC:user:authorization 的 value)",
|
|
189
|
+
placeholder: "eyJhbGciOi...",
|
|
190
|
+
validate: (v) => (String(v || "").trim().startsWith("eyJ") ? undefined : "不是 JWT,要以 eyJ 开头"),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
type: "text",
|
|
194
|
+
key: "org",
|
|
195
|
+
message: "组织码(MAGIC:user:organizationCode 的 value)",
|
|
196
|
+
placeholder: "orgcode67890",
|
|
197
|
+
validate: (v) => (/^[a-z0-9]{6,32}$/i.test(String(v || "").trim()) ? undefined : "12 位左右的字母数字"),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
async authorize(inputs) {
|
|
201
|
+
const jwt = String(inputs?.jwt || "").trim().replace(/^Bearer\s+/i, "")
|
|
202
|
+
const org = String(inputs?.org || "").trim()
|
|
203
|
+
if (!jwt.startsWith("eyJ")) return { type: "failed", error: "不是 JWT" }
|
|
204
|
+
if (!org) return { type: "failed", error: "没填组织码" }
|
|
205
|
+
|
|
206
|
+
const result = (async () => {
|
|
207
|
+
await fetchPoints(jwt, org, c.host)
|
|
208
|
+
return issueGatewayKey(jwt, org, c.host)
|
|
209
|
+
})()
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
url: `${c.web}/login`,
|
|
213
|
+
instructions: "正在验证登录态…",
|
|
214
|
+
method: "auto",
|
|
215
|
+
opened: false,
|
|
216
|
+
async callback() {
|
|
217
|
+
let r
|
|
218
|
+
try {
|
|
219
|
+
r = await result
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return { type: "failed", error: String(e?.message ?? e) }
|
|
222
|
+
}
|
|
223
|
+
writeSession(c.id, jwt, org)
|
|
224
|
+
noteOrg(c.id, org)
|
|
225
|
+
await rollInvite(c.id, jwt, org, c.host)
|
|
226
|
+
// 卡片上显示手机号/邮箱/昵称,取不到就退回组织码
|
|
227
|
+
const who = await fetchWho(jwt, org, c.host).catch(() => null)
|
|
228
|
+
return {
|
|
229
|
+
type: "success",
|
|
230
|
+
access: r.apiKey,
|
|
231
|
+
refresh: r.refreshToken,
|
|
232
|
+
expires: Date.now() + 2 * 3600_000,
|
|
233
|
+
accountId: who?.name || org,
|
|
234
|
+
uid: org,
|
|
235
|
+
jwt,
|
|
236
|
+
org,
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
]
|
|
243
|
+
}
|
package/src/keys.mjs
CHANGED
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 登录成功 → issueAndStore() 签一把放进「另一个槽」,切过去
|
|
4
4
|
// loader → ensureKey() 快过期就把新的签到另一个槽,切过去;旧的留着当回退
|
|
5
|
-
// refresh →
|
|
5
|
+
// refresh → ⚠ **不经过本模块**。src/hooks/refresh.mjs 自己调
|
|
6
|
+
// api.refreshGatewayKey(auth.refresh),magpie 拿到的不是这一把。
|
|
6
7
|
//
|
|
7
8
|
// **每个账号一套槽,互不干扰** —— 同站点登两个账号时,第二个人登录不会碰第一个人的 key。
|
|
8
9
|
// 账号标识用 `org`(组织码),拿不到才退回 accountId。
|
|
10
|
+
//
|
|
11
|
+
// ⚠ 上面那条 refresh 是「唯一出口」这个设计里**唯一没落地的口子**(2026-10-27 核对):
|
|
12
|
+
// refresh_token 是一次性轮换的,两条路径各用各的那份会分叉 ——
|
|
13
|
+
// ensureKey 轮换后 store 里是新值,refresh.mjs 手里 magpie 的还是旧值,
|
|
14
|
+
// 再调就是 401 → magpie 判定「登录已失效」→ 让用户重新登录。
|
|
15
|
+
//
|
|
16
|
+
// 目前不会触发,因为 **store 从来没被写过**(本机 magicrew-key-*.json 一个都没有,
|
|
17
|
+
// 说明 ensureKey 从没执行过 —— 没发过模型请求)。但这意味着:
|
|
18
|
+
// **若将来要给 checkin 补 ensureKey,必须先把这个钩子也收敛进来**,
|
|
19
|
+
// 否则就是亲手把「一条路」掰成两条,用「多写一句文档」换「用户莫名掉登录」。
|
|
9
20
|
|
|
10
21
|
import { issueGatewayKey, refreshGatewayKey } from "./api.mjs"
|
|
11
22
|
import {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// 从**本机浏览器的 localStorage** 里读 magicrew 登录态 —— 不开窗口、不让用户粘任何东西。
|
|
2
|
+
//
|
|
3
|
+
// ═══ 为什么需要这条路 ═══════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// magicrew 没有 OAuth 回调通道(见 docs/internals.md 第九节),所以「用户在网页上
|
|
6
|
+
// 登录成功」这件事,插件原本只能靠「自己起的那个浏览器」知道。于是有一个反复被报的
|
|
7
|
+
// 现象:**用户在自己浏览器里登好了,插件里却没有多出账号** —— 因为那是另一个浏览器。
|
|
8
|
+
//
|
|
9
|
+
// 但凭据其实就躺在本地:Chromium 系(Edge / Chrome / Brave)把 localStorage 明文写在
|
|
10
|
+
//
|
|
11
|
+
// <User Data>/<Default|Profile N>/Local Storage/leveldb/*.log|*.ldb
|
|
12
|
+
//
|
|
13
|
+
// 里,key 是 `_<origin>\x00\x01<name>`,值是 JSON。直接读文件就行,不用 CDP、不用开窗口。
|
|
14
|
+
//
|
|
15
|
+
// 读法:把 leveldb 目录按 latin1 拼成文本 → 以站点 host 出现的位置开一个窗口 →
|
|
16
|
+
// 在同一个窗口里找 `"key":"authorization"` 与 `"key":"organizationCode2?"`。
|
|
17
|
+
// LevelDB 按 key 排序,同一 origin 的记录挨在一起,所以窗口内配对是可靠的
|
|
18
|
+
// (实测本机 Edge:`www.letsmagic.cn` 命中、`www.magicrew.ai` 不命中,不会串)。
|
|
19
|
+
//
|
|
20
|
+
// 成本:本机 Edge 27 MB / 34 ms。全部浏览器扫一遍通常 < 300 ms。
|
|
21
|
+
//
|
|
22
|
+
// ⚠ 只读。不写文件、不改浏览器数据、不碰 cookie。
|
|
23
|
+
|
|
24
|
+
import { homedir } from "node:os"
|
|
25
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
26
|
+
import { join } from "node:path"
|
|
27
|
+
|
|
28
|
+
/** 同一个 origin 的若干条记录(theme / i18n / userInfo / authorization …)挤在一起,
|
|
29
|
+
* 2000 字节足够覆盖到 organizationCode;太小会漏、太大可能跨到另一个站点 */
|
|
30
|
+
const WINDOW = 2000
|
|
31
|
+
|
|
32
|
+
/** 各平台的浏览器 User Data 根目录;不存在就跳过 */
|
|
33
|
+
function roots() {
|
|
34
|
+
const h = homedir()
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
const local = process.env.LOCALAPPDATA || join(h, "AppData", "Local")
|
|
37
|
+
return [
|
|
38
|
+
["Edge", join(local, "Microsoft", "Edge", "User Data")],
|
|
39
|
+
["Chrome", join(local, "Google", "Chrome", "User Data")],
|
|
40
|
+
["Brave", join(local, "BraveSoftware", "Brave-Browser", "User Data")],
|
|
41
|
+
["Chromium", join(local, "Chromium", "User Data")],
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
if (process.platform === "darwin") {
|
|
45
|
+
return [
|
|
46
|
+
["Chrome", join(h, "Library/Application Support/Google/Chrome")],
|
|
47
|
+
["Edge", join(h, "Library/Application Support/Microsoft Edge")],
|
|
48
|
+
["Brave", join(h, "Library/Application Support/BraveSoftware/Brave-Browser")],
|
|
49
|
+
["Chromium", join(h, "Library/Application Support/Chromium")],
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
return [
|
|
53
|
+
["Chrome", join(h, ".config/google-chrome")],
|
|
54
|
+
["Chromium", join(h, ".config/chromium")],
|
|
55
|
+
["Edge", join(h, ".config/microsoft-edge")],
|
|
56
|
+
["Brave", join(h, ".config/BraveSoftware/Brave-Browser")],
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function profilesOf(root) {
|
|
61
|
+
try {
|
|
62
|
+
return readdirSync(root).filter((n) => n === "Default" || /^Profile \d+$/.test(n))
|
|
63
|
+
} catch {
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function leveldbText(root, profile) {
|
|
69
|
+
const dir = join(root, profile, "Local Storage", "leveldb")
|
|
70
|
+
let files
|
|
71
|
+
try {
|
|
72
|
+
files = readdirSync(dir)
|
|
73
|
+
} catch {
|
|
74
|
+
return ""
|
|
75
|
+
}
|
|
76
|
+
let text = ""
|
|
77
|
+
for (const f of files) {
|
|
78
|
+
try {
|
|
79
|
+
text += readFileSync(join(dir, f)).toString("latin1")
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
return text
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** jwt 里的 iat / exp;解不出来返回 0 */
|
|
86
|
+
function jwtTimes(jwt) {
|
|
87
|
+
try {
|
|
88
|
+
const body = JSON.parse(Buffer.from(String(jwt).split(".")[1] || "", "base64url").toString("utf8"))
|
|
89
|
+
return { iat: Number(body.iat) || 0, exp: Number(body.exp) || 0 }
|
|
90
|
+
} catch {
|
|
91
|
+
return { iat: 0, exp: 0 }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** LevelDB 里一条记录的前缀:`_https://<origin>\x00\x01<name>` */
|
|
96
|
+
const ORIGIN_RE = /_https?:\/\/([^\x00\x01"\\]{4,80})\x00\x01/g
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 在 host 每一次出现的位置开窗,取窗口内同一 origin 的 jwt × org 组合。
|
|
100
|
+
*
|
|
101
|
+
* ⚠ 必须按 origin 截断:两个站点的记录可能相邻(用户两个都登录过),
|
|
102
|
+
* 2000 字节的窗口会把国际站的 org 配到国内站的 jwt 上。
|
|
103
|
+
* 截断点 = 「下一个**不同站点**的 origin」,同站点的多条记录不截断。
|
|
104
|
+
*/
|
|
105
|
+
function credsNear(text, host, win = WINDOW) {
|
|
106
|
+
const bare = host.replace(/^www\./, "")
|
|
107
|
+
const origins = []
|
|
108
|
+
for (const m of text.matchAll(ORIGIN_RE)) origins.push({ host: m[1], at: m.index })
|
|
109
|
+
|
|
110
|
+
const out = []
|
|
111
|
+
let at = -1
|
|
112
|
+
while ((at = text.indexOf(host, at + 1)) !== -1) {
|
|
113
|
+
let stop = Math.min(text.length, at + win)
|
|
114
|
+
for (const o of origins) {
|
|
115
|
+
if (o.at > at && !o.host.includes(bare)) {
|
|
116
|
+
stop = o.at
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const w = text.slice(at, stop)
|
|
121
|
+
// 每把 jwt 配**它自己后面**最近的那个组织码:
|
|
122
|
+
// LevelDB 按 key 排序(authorization < organizationCode),同一批写入的挨在一起;
|
|
123
|
+
// 取「窗口内第一个 org」会在同一站点存过多把凭据时配错。
|
|
124
|
+
for (const m of w.matchAll(/"key":"authorization","value":"(eyJ[^"]+)"/g)) {
|
|
125
|
+
const tail = w.slice(m.index)
|
|
126
|
+
const org = (tail.match(/"key":"organizationCode2?","value":"([^"]+)"/) || [])[1]
|
|
127
|
+
if (org) out.push({ jwt: m[1], org })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 读本机浏览器里属于这个站点的登录态。
|
|
135
|
+
*
|
|
136
|
+
* @param {string} hostname 例如 `www.letsmagic.cn`
|
|
137
|
+
* @returns {{jwt:string, org:string, browser:string, profile:string, host:string, iat:number} | null}
|
|
138
|
+
* 取 iat 最新、且未过期的那一把;读不到返回 null(调用方降级到窗口登录)。
|
|
139
|
+
*/
|
|
140
|
+
export function readLocalCreds(hostname, { now = Date.now() } = {}) {
|
|
141
|
+
const bare = String(hostname || "").trim().replace(/^www\./, "")
|
|
142
|
+
if (!bare) return null
|
|
143
|
+
const hosts = [hostname, bare, `www.${bare}`].filter((v, i, a) => v && a.indexOf(v) === i)
|
|
144
|
+
|
|
145
|
+
let best = null
|
|
146
|
+
for (const [browser, root] of roots()) {
|
|
147
|
+
for (const profile of profilesOf(root)) {
|
|
148
|
+
const text = leveldbText(root, profile)
|
|
149
|
+
if (!text) continue
|
|
150
|
+
for (const host of hosts) {
|
|
151
|
+
for (const c of credsNear(text, host)) {
|
|
152
|
+
const { iat, exp } = jwtTimes(c.jwt)
|
|
153
|
+
if (!iat) continue
|
|
154
|
+
if (exp && exp * 1000 < now) continue
|
|
155
|
+
if (!best || iat > best.iat) best = { ...c, iat, browser, profile, host }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return best
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const _internal = { roots, profilesOf, leveldbText, credsNear, jwtTimes, WINDOW }
|