@mkterswingman/5mghost-wonder 0.0.1
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/dist/auth/runtime.js +15 -0
- package/dist/cli.js +75 -0
- package/dist/commands/auth.js +100 -0
- package/dist/commands/check.js +258 -0
- package/dist/commands/help.js +38 -0
- package/dist/commands/index.js +50 -0
- package/dist/commands/read.js +198 -0
- package/dist/commands/setup.js +81 -0
- package/dist/commands/types.js +4 -0
- package/dist/commands/uninstall.js +14 -0
- package/dist/commands/update.js +21 -0
- package/dist/commands/version.js +8 -0
- package/dist/commands/wecom.js +136 -0
- package/dist/platform/npm.js +14 -0
- package/dist/platform/paths.js +25 -0
- package/dist/telemetry/events.js +42 -0
- package/dist/telemetry/policy.js +51 -0
- package/dist/telemetry/runtime.js +31 -0
- package/dist/wecom/browser.js +344 -0
- package/dist/wecom/cache.js +119 -0
- package/dist/wecom/cookies.js +151 -0
- package/dist/wecom/export.js +236 -0
- package/dist/wecom/url.js +45 -0
- package/dist/wecom/url.test.js +64 -0
- package/dist/xlsx/drawing.js +131 -0
- package/dist/xlsx/metadata.js +34 -0
- package/dist/xlsx/parse-tab.js +124 -0
- package/dist/xlsx/shared-strings.js +51 -0
- package/dist/xlsx/sheet.js +161 -0
- package/dist/xlsx/styles.js +85 -0
- package/dist/xlsx/unzip.js +33 -0
- package/dist/xlsx/workbook.js +51 -0
- package/dist/xlsx/workbook.test.js +19 -0
- package/package.json +41 -0
- package/scripts/check-export-types.mjs +37 -0
- package/scripts/postinstall.mjs +50 -0
- package/skills/setup-5mghost-wonder/SKILL.md +245 -0
- package/skills/use-5mghost-wonder/SKILL.md +240 -0
- package/skills.manifest.json +36 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// src/wecom/browser.ts
|
|
2
|
+
// CDP cookie extraction: spawn Chrome/Edge → WebSocket → Storage.getCookies poll.
|
|
3
|
+
// Uses ws package (NOT native WebSocket) for Node 18/20 compatibility.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { createServer } from "node:net";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import WebSocket from "ws";
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Constants (validated in /tmp/wonder-cookie-grab.ts prototype)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const LOGIN_URL = "https://doc.weixin.qq.com";
|
|
13
|
+
const LOGIN_TIMEOUT_MS = 3 * 60 * 1000;
|
|
14
|
+
const COOKIE_POLL_INTERVAL_MS = 2000;
|
|
15
|
+
const CDP_CALL_TIMEOUT_MS = 5000;
|
|
16
|
+
const MIN_KEY_COOKIES_REQUIRED = 3;
|
|
17
|
+
const KEY_COOKIES = [
|
|
18
|
+
"TOK",
|
|
19
|
+
"wedrive_sid",
|
|
20
|
+
"uid",
|
|
21
|
+
"uid_key",
|
|
22
|
+
"wedrive_ticket",
|
|
23
|
+
"wedrive_skey",
|
|
24
|
+
];
|
|
25
|
+
const TRUSTED_DOMAINS = ["doc.weixin.qq.com", "weixin.qq.com"];
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Security helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* C-03: Validate that a debugger URL is a localhost HTTP endpoint.
|
|
31
|
+
* Prevents connecting to remote CDP endpoints which would expose all cookies.
|
|
32
|
+
*/
|
|
33
|
+
function assertLocalhostUrl(url) {
|
|
34
|
+
let u;
|
|
35
|
+
try {
|
|
36
|
+
u = new URL(url);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`debuggerUrl is not a valid URL: ${url}`);
|
|
40
|
+
}
|
|
41
|
+
if (u.hostname !== "127.0.0.1" && u.hostname !== "localhost") {
|
|
42
|
+
throw new Error(`debuggerUrl must be a localhost address, got: ${url}`);
|
|
43
|
+
}
|
|
44
|
+
if (u.protocol !== "http:") {
|
|
45
|
+
throw new Error(`debuggerUrl must use http:, got: ${url}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Spawn Chrome → connect via CDP WebSocket → poll Storage.getCookies until
|
|
50
|
+
* ≥ MIN_KEY_COOKIES_REQUIRED key cookies are present → return Record<string,string>.
|
|
51
|
+
*/
|
|
52
|
+
export async function collectWecomCookiesViaBrowser(options) {
|
|
53
|
+
const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
54
|
+
let endpoint = options.debuggerUrl;
|
|
55
|
+
let child = null;
|
|
56
|
+
// C-03: Validate that a provided debuggerUrl is a localhost endpoint.
|
|
57
|
+
if (endpoint) {
|
|
58
|
+
assertLocalhostUrl(endpoint);
|
|
59
|
+
}
|
|
60
|
+
if (!endpoint) {
|
|
61
|
+
const browser = findInstalledBrowser(options.executablePath);
|
|
62
|
+
const port = await getFreePort();
|
|
63
|
+
endpoint = `http://127.0.0.1:${port}`;
|
|
64
|
+
mkdirSync(options.chromeProfilePath, { recursive: true });
|
|
65
|
+
options.io?.stdout(`Opening ${browser.label} for WeCom login...`);
|
|
66
|
+
child = spawn(browser.executablePath, [
|
|
67
|
+
`--remote-debugging-port=${port}`,
|
|
68
|
+
`--user-data-dir=${options.chromeProfilePath}`,
|
|
69
|
+
"--no-first-run",
|
|
70
|
+
"--no-default-browser-check",
|
|
71
|
+
"--disable-features=DialMediaRouteProvider",
|
|
72
|
+
LOGIN_URL,
|
|
73
|
+
], {
|
|
74
|
+
stdio: "ignore",
|
|
75
|
+
detached: process.platform !== "win32",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await waitForDebugger(endpoint, 20_000);
|
|
80
|
+
const wsUrl = await getWebSocketDebuggerUrl(endpoint, 20_000);
|
|
81
|
+
const socket = await openDevToolsSocket(wsUrl);
|
|
82
|
+
try {
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
85
|
+
const cookies = await readWecomCookies(socket);
|
|
86
|
+
if (cookies !== null)
|
|
87
|
+
return cookies;
|
|
88
|
+
await delay(COOKIE_POLL_INTERVAL_MS);
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Timed out waiting for WeCom login after ${Math.round(timeoutMs / 1000)}s. ` +
|
|
91
|
+
"Sign in to WeCom in the opened browser, then rerun `wonder wecom cookie`.");
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
socket.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
if (child)
|
|
99
|
+
await terminateBrowserProcess(child);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function getKnownBrowserPaths() {
|
|
103
|
+
if (process.platform === "darwin") {
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
label: "Google Chrome",
|
|
107
|
+
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
label: "Microsoft Edge",
|
|
111
|
+
executablePath: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
if (process.platform === "win32") {
|
|
116
|
+
const lad = process.env["LOCALAPPDATA"] ?? "";
|
|
117
|
+
const pf = process.env["PROGRAMFILES"] ?? "C:\\Program Files";
|
|
118
|
+
const pf86 = process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
label: "Google Chrome",
|
|
122
|
+
executablePath: join(lad, "Google", "Chrome", "Application", "chrome.exe"),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: "Google Chrome",
|
|
126
|
+
executablePath: join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
label: "Google Chrome",
|
|
130
|
+
executablePath: join(pf86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
label: "Microsoft Edge",
|
|
134
|
+
executablePath: join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
label: "Microsoft Edge",
|
|
138
|
+
executablePath: join(pf86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
label: "Microsoft Edge",
|
|
142
|
+
executablePath: join(lad, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
// Linux
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
label: "Google Chrome",
|
|
150
|
+
executablePath: "/usr/bin/google-chrome-stable",
|
|
151
|
+
},
|
|
152
|
+
{ label: "Google Chrome", executablePath: "/usr/bin/google-chrome" },
|
|
153
|
+
{ label: "Chromium", executablePath: "/usr/bin/chromium" },
|
|
154
|
+
{ label: "Chromium", executablePath: "/usr/bin/chromium-browser" },
|
|
155
|
+
{ label: "Microsoft Edge", executablePath: "/usr/bin/microsoft-edge" },
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
function findInstalledBrowser(executablePath) {
|
|
159
|
+
if (executablePath) {
|
|
160
|
+
// C-02: validate path exists before passing to spawn to prevent arbitrary execution.
|
|
161
|
+
if (!existsSync(executablePath)) {
|
|
162
|
+
throw new Error(`Configured browser not found: ${executablePath}`);
|
|
163
|
+
}
|
|
164
|
+
return { label: "Configured browser", executablePath };
|
|
165
|
+
}
|
|
166
|
+
for (const candidate of getKnownBrowserPaths()) {
|
|
167
|
+
if (existsSync(candidate.executablePath)) {
|
|
168
|
+
return candidate;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error("Could not find a supported Chrome/Edge installation.\n" +
|
|
172
|
+
"Install Chrome from https://www.google.com/chrome/, then rerun `wonder wecom cookie`.");
|
|
173
|
+
}
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// CDP helpers
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// C-04: Monotonically increasing counter for CDP message IDs to avoid
|
|
178
|
+
// collision when multiple commands are in-flight on the same WebSocket.
|
|
179
|
+
let _cdpNextId = 1;
|
|
180
|
+
function delay(ms) {
|
|
181
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
182
|
+
}
|
|
183
|
+
async function getFreePort() {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const server = createServer();
|
|
186
|
+
server.listen(0, "127.0.0.1", () => {
|
|
187
|
+
const address = server.address();
|
|
188
|
+
if (!address || typeof address === "string") {
|
|
189
|
+
server.close();
|
|
190
|
+
reject(new Error("Failed to allocate a browser debugging port"));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const port = address.port;
|
|
194
|
+
// C-01: Resolve only after the server is fully closed so the caller
|
|
195
|
+
// can spawn Chrome with the port number immediately after resolution,
|
|
196
|
+
// minimising the TOCTOU window between close and bind.
|
|
197
|
+
server.close((err) => {
|
|
198
|
+
if (err)
|
|
199
|
+
reject(err);
|
|
200
|
+
else
|
|
201
|
+
resolve(port);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
server.on("error", reject);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function waitForDebugger(endpoint, timeoutMs) {
|
|
208
|
+
const startedAt = Date.now();
|
|
209
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(`${endpoint}/json/version`);
|
|
212
|
+
if (res.ok)
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Browser startup races are expected; keep polling until timeout.
|
|
217
|
+
}
|
|
218
|
+
await delay(500);
|
|
219
|
+
}
|
|
220
|
+
throw new Error("Browser did not expose the remote debugging endpoint in time");
|
|
221
|
+
}
|
|
222
|
+
async function getWebSocketDebuggerUrl(endpoint, timeoutMs) {
|
|
223
|
+
const startedAt = Date.now();
|
|
224
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
225
|
+
try {
|
|
226
|
+
const res = await fetch(`${endpoint}/json/version`);
|
|
227
|
+
if (res.ok) {
|
|
228
|
+
const payload = (await res.json());
|
|
229
|
+
if (payload.webSocketDebuggerUrl)
|
|
230
|
+
return payload.webSocketDebuggerUrl;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Browser startup races are expected; keep polling until timeout.
|
|
235
|
+
}
|
|
236
|
+
await delay(500);
|
|
237
|
+
}
|
|
238
|
+
throw new Error("Browser did not expose a DevTools websocket in time");
|
|
239
|
+
}
|
|
240
|
+
async function openDevToolsSocket(url) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const socket = new WebSocket(url);
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
socket.close();
|
|
245
|
+
reject(new Error("Timed out connecting to the browser DevTools websocket"));
|
|
246
|
+
}, CDP_CALL_TIMEOUT_MS);
|
|
247
|
+
const handleOpen = () => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
socket.removeEventListener("error", handleError);
|
|
250
|
+
resolve(socket);
|
|
251
|
+
};
|
|
252
|
+
const handleError = () => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
socket.removeEventListener("open", handleOpen);
|
|
255
|
+
reject(new Error("Failed to connect to the browser DevTools websocket"));
|
|
256
|
+
};
|
|
257
|
+
socket.addEventListener("open", handleOpen);
|
|
258
|
+
socket.addEventListener("error", handleError);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async function sendCdpCommand(socket, method, params = {}) {
|
|
262
|
+
const id = _cdpNextId++;
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
const timer = setTimeout(() => {
|
|
265
|
+
socket.removeEventListener("message", handleMessage);
|
|
266
|
+
reject(new Error(`CDP timeout: ${method}`));
|
|
267
|
+
}, CDP_CALL_TIMEOUT_MS);
|
|
268
|
+
const handleMessage = (event) => {
|
|
269
|
+
const payload = JSON.parse(String(event.data));
|
|
270
|
+
if (payload.id !== id)
|
|
271
|
+
return;
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
socket.removeEventListener("message", handleMessage);
|
|
274
|
+
if (payload.error) {
|
|
275
|
+
reject(new Error(payload.error.message ?? `CDP error: ${method}`));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
resolve((payload.result ?? {}));
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
socket.addEventListener("message", handleMessage);
|
|
282
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Cookie filtering
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
function isTrustedDomain(domain) {
|
|
289
|
+
const d = domain.trim().toLowerCase().replace(/^\./, "");
|
|
290
|
+
return TRUSTED_DOMAINS.some((trusted) => d === trusted || d.endsWith(`.${trusted}`));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Poll CDP for WeCom cookies. Returns Record<string,string> when
|
|
294
|
+
* MIN_KEY_COOKIES_REQUIRED are present, null otherwise.
|
|
295
|
+
*/
|
|
296
|
+
async function readWecomCookies(socket) {
|
|
297
|
+
const payload = await sendCdpCommand(socket, "Storage.getCookies");
|
|
298
|
+
const trusted = (payload.cookies ?? []).filter((c) => isTrustedDomain(c.domain));
|
|
299
|
+
const cookieNames = new Set(trusted.map((c) => c.name));
|
|
300
|
+
const presentKeys = KEY_COOKIES.filter((k) => cookieNames.has(k));
|
|
301
|
+
if (presentKeys.length < MIN_KEY_COOKIES_REQUIRED)
|
|
302
|
+
return null;
|
|
303
|
+
// Build Record; later entries overwrite earlier ones for the same name.
|
|
304
|
+
const result = {};
|
|
305
|
+
for (const c of trusted) {
|
|
306
|
+
result[c.name] = c.value;
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Browser process cleanup
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
async function terminateBrowserProcess(child) {
|
|
314
|
+
if (child.exitCode !== null)
|
|
315
|
+
return;
|
|
316
|
+
// W-08: On macOS/Linux Chrome is spawned with detached:true, so child.kill()
|
|
317
|
+
// only signals the direct process. Send to the whole process group (-pid) to
|
|
318
|
+
// ensure renderer sub-processes release the --user-data-dir lock.
|
|
319
|
+
if (process.platform !== "win32" && child.pid) {
|
|
320
|
+
try {
|
|
321
|
+
process.kill(-child.pid, "SIGTERM");
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
child.kill();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
child.kill();
|
|
329
|
+
}
|
|
330
|
+
await delay(500);
|
|
331
|
+
if (child.exitCode === null) {
|
|
332
|
+
try {
|
|
333
|
+
if (process.platform !== "win32" && child.pid) {
|
|
334
|
+
process.kill(-child.pid, "SIGKILL");
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
child.kill("SIGKILL");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// Already dead — ignore.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/wecom/cache.ts
|
|
2
|
+
// Local xlsx/docx/pptx cache keyed by (docId, TOK-hash).
|
|
3
|
+
//
|
|
4
|
+
// Purpose: `wonder read <url> --tab <name>` should not repeat the
|
|
5
|
+
// export_office → poll → download loop when the same document was just
|
|
6
|
+
// exported a minute ago. The export API does not expose a content hash or
|
|
7
|
+
// mtime, so we use a short TTL and the TOK cookie as a coarse freshness
|
|
8
|
+
// signal (if the user re-authenticated, cache is invalidated).
|
|
9
|
+
//
|
|
10
|
+
// Layout:
|
|
11
|
+
// <cacheDir>/<docId>/<tokHash>.meta.json
|
|
12
|
+
// <cacheDir>/<docId>/<tokHash>.<ext> (the actual downloaded file)
|
|
13
|
+
//
|
|
14
|
+
// The meta file records fileName, fileSizeBytes, docType, savedAt, ttlMs.
|
|
15
|
+
// The file itself lives next to meta so we can hand its path back directly.
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, statSync, copyFileSync, } from "node:fs";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { join, extname } from "node:path";
|
|
19
|
+
/** Default cache TTL: 10 minutes. Long enough to cover a `read` followed
|
|
20
|
+
* immediately by one or two `--tab` calls, short enough that stale data
|
|
21
|
+
* does not accumulate if the user is editing the source document. */
|
|
22
|
+
export const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
23
|
+
function hashTok(tok) {
|
|
24
|
+
// Short, filesystem-safe identifier. Not used for auth — only cache keying.
|
|
25
|
+
return createHash("sha256").update(tok).digest("hex").slice(0, 16);
|
|
26
|
+
}
|
|
27
|
+
function entryPaths(cacheDir, docId, tokHash) {
|
|
28
|
+
const dir = join(cacheDir, docId);
|
|
29
|
+
return {
|
|
30
|
+
dir,
|
|
31
|
+
metaPath: join(dir, `${tokHash}.meta.json`),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function lookupCachedExport(input) {
|
|
35
|
+
const tokHash = hashTok(input.tokValue);
|
|
36
|
+
const { dir, metaPath } = entryPaths(input.cacheDir, input.docId, tokHash);
|
|
37
|
+
if (!existsSync(metaPath))
|
|
38
|
+
return null;
|
|
39
|
+
let meta;
|
|
40
|
+
try {
|
|
41
|
+
meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const age = Date.now() - meta.savedAtMs;
|
|
47
|
+
if (age < 0 || age > input.maxAgeMs)
|
|
48
|
+
return null;
|
|
49
|
+
const filePath = join(dir, meta.fileBasename);
|
|
50
|
+
if (!existsSync(filePath))
|
|
51
|
+
return null;
|
|
52
|
+
// Size sanity check — cheap guard against truncated cache
|
|
53
|
+
try {
|
|
54
|
+
const st = statSync(filePath);
|
|
55
|
+
if (st.size !== meta.fileSizeBytes)
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
filePath,
|
|
63
|
+
fileName: meta.fileName,
|
|
64
|
+
fileSizeBytes: meta.fileSizeBytes,
|
|
65
|
+
docType: meta.docType,
|
|
66
|
+
savedAtMs: meta.savedAtMs,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function saveExportToCache(input) {
|
|
70
|
+
const tokHash = hashTok(input.tokValue);
|
|
71
|
+
const { dir, metaPath } = entryPaths(input.cacheDir, input.docId, tokHash);
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
// Copy via the filesystem to avoid loading the entire file into a V8
|
|
74
|
+
// Buffer — xlsx exports can be hundreds of MB and would double-allocate
|
|
75
|
+
// (once in the caller, once here) if we went through readFileSync.
|
|
76
|
+
const ext = extname(input.sourceFilePath) || ".bin";
|
|
77
|
+
const fileBasename = `${tokHash}${ext}`;
|
|
78
|
+
const cachedFilePath = join(dir, fileBasename);
|
|
79
|
+
const tmp = `${cachedFilePath}.tmp`;
|
|
80
|
+
copyFileSync(input.sourceFilePath, tmp);
|
|
81
|
+
try {
|
|
82
|
+
renameSync(tmp, cachedFilePath);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
try {
|
|
86
|
+
rmSync(tmp, { force: true });
|
|
87
|
+
}
|
|
88
|
+
catch { /* ignore */ }
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
const meta = {
|
|
92
|
+
fileName: input.fileName,
|
|
93
|
+
fileSizeBytes: input.fileSizeBytes,
|
|
94
|
+
docType: input.docType,
|
|
95
|
+
savedAtMs: Date.now(),
|
|
96
|
+
fileBasename,
|
|
97
|
+
};
|
|
98
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8");
|
|
99
|
+
return {
|
|
100
|
+
filePath: cachedFilePath,
|
|
101
|
+
fileName: input.fileName,
|
|
102
|
+
fileSizeBytes: input.fileSizeBytes,
|
|
103
|
+
docType: input.docType,
|
|
104
|
+
savedAtMs: meta.savedAtMs,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Diagnostic helper used by `wonder check` to describe cache state. */
|
|
108
|
+
export function describeCacheDir(cacheDir) {
|
|
109
|
+
if (!existsSync(cacheDir))
|
|
110
|
+
return { exists: false, entries: 0 };
|
|
111
|
+
try {
|
|
112
|
+
const subdirs = readdirSync(cacheDir, { withFileTypes: true })
|
|
113
|
+
.filter((d) => d.isDirectory());
|
|
114
|
+
return { exists: true, entries: subdirs.length };
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { exists: true, entries: 0 };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/wecom/cookies.ts
|
|
2
|
+
// Cookie persistence: load / save / validate / status / parseCookieInput.
|
|
3
|
+
// Storage: ~/.wonder/cookies.json (atomic write via tmp+rename, chmod 600)
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Atomic write helper
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
function writeCookiesAtomic(cookiesPath, cookies) {
|
|
10
|
+
mkdirSync(dirname(cookiesPath), { recursive: true });
|
|
11
|
+
const tmp = `${cookiesPath}.tmp`;
|
|
12
|
+
writeFileSync(tmp, JSON.stringify(cookies, null, 2), {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
mode: 0o600,
|
|
15
|
+
});
|
|
16
|
+
// W-09: clean up the tmp file if rename fails (e.g. cross-device EXDEV or
|
|
17
|
+
// permission error) to avoid leaving plaintext cookies on disk.
|
|
18
|
+
try {
|
|
19
|
+
renameSync(tmp, cookiesPath);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
try {
|
|
23
|
+
rmSync(tmp, { force: true });
|
|
24
|
+
}
|
|
25
|
+
catch { /* ignore secondary error */ }
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
chmodSync(cookiesPath, 0o600);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// chmod failure is expected on Windows; do not interrupt the flow.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// load / save / remove
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
export function loadCookies(cookiesPath) {
|
|
39
|
+
if (!existsSync(cookiesPath))
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(readFileSync(cookiesPath, "utf8"));
|
|
43
|
+
if (typeof raw === "object" &&
|
|
44
|
+
raw !== null &&
|
|
45
|
+
!Array.isArray(raw)) {
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function saveCookies(cookiesPath, cookies) {
|
|
55
|
+
writeCookiesAtomic(cookiesPath, cookies);
|
|
56
|
+
}
|
|
57
|
+
export function removeCookies(cookiesPath) {
|
|
58
|
+
rmSync(cookiesPath, { force: true });
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Live validation (HTTP probe)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Probe a real WeCom API endpoint to confirm cookies are still valid.
|
|
65
|
+
*
|
|
66
|
+
* - HTTP 200 or 400 ("operation not found") → cookies valid
|
|
67
|
+
* - HTTP 401 or 403 → cookies expired
|
|
68
|
+
* - Network error → conservatively return true (avoid false "expired" on offline)
|
|
69
|
+
*/
|
|
70
|
+
export async function validateCookies(cookies) {
|
|
71
|
+
// W-02: strip `;` and `\n` from cookie values to prevent header injection.
|
|
72
|
+
const cookieHeader = Object.entries(cookies)
|
|
73
|
+
.map(([k, v]) => `${k}=${String(v).replace(/[;\n]/g, "")}`)
|
|
74
|
+
.join("; ");
|
|
75
|
+
const tok = cookies["TOK"] ?? "";
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(`https://doc.weixin.qq.com/v1/export/query_progress?operationId=PROBE_VALIDITY_CHECK&xsrf=${encodeURIComponent(tok)}`, { headers: { Cookie: cookieHeader } });
|
|
78
|
+
return res.status !== 401 && res.status !== 403;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Network error: conservatively treat as valid.
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Status
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
export async function getCookieStatus(cookiesPath) {
|
|
89
|
+
const cookies = loadCookies(cookiesPath);
|
|
90
|
+
if (!cookies) {
|
|
91
|
+
return { exists: false, valid: null, path: cookiesPath, error: null };
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const valid = await validateCookies(cookies);
|
|
95
|
+
return { exists: true, valid, path: cookiesPath, error: null };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return {
|
|
99
|
+
exists: true,
|
|
100
|
+
valid: null,
|
|
101
|
+
path: cookiesPath,
|
|
102
|
+
error: String(err),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// parseCookieInput (for wonder wecom set-cookie)
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
/**
|
|
110
|
+
* Parse a cookie string in one of two formats:
|
|
111
|
+
*
|
|
112
|
+
* Format A — JSON object:
|
|
113
|
+
* '{"TOK":"xxx","wedrive_sid":"yyy"}'
|
|
114
|
+
*
|
|
115
|
+
* Format B — HTTP Cookie header:
|
|
116
|
+
* 'TOK=xxx; wedrive_sid=yyy; uid=zzz'
|
|
117
|
+
*
|
|
118
|
+
* Returns null if the input is empty or cannot be parsed.
|
|
119
|
+
*/
|
|
120
|
+
export function parseCookieInput(input) {
|
|
121
|
+
const trimmed = input.trim();
|
|
122
|
+
if (!trimmed)
|
|
123
|
+
return null;
|
|
124
|
+
// Try JSON first
|
|
125
|
+
if (trimmed.startsWith("{")) {
|
|
126
|
+
try {
|
|
127
|
+
const obj = JSON.parse(trimmed);
|
|
128
|
+
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
|
|
129
|
+
return obj;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Fall through to cookie-header parse.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Try "key=value; key2=value2" format
|
|
137
|
+
const pairs = trimmed
|
|
138
|
+
.split(";")
|
|
139
|
+
.map((p) => p.trim())
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
const result = {};
|
|
142
|
+
for (const pair of pairs) {
|
|
143
|
+
const eqIdx = pair.indexOf("=");
|
|
144
|
+
if (eqIdx < 1)
|
|
145
|
+
return null; // malformed
|
|
146
|
+
const key = pair.slice(0, eqIdx).trim();
|
|
147
|
+
const value = pair.slice(eqIdx + 1).trim();
|
|
148
|
+
result[key] = value;
|
|
149
|
+
}
|
|
150
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
151
|
+
}
|