bdip-office-assistant-cli 0.0.0-stage → 1.0.0
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/README.md +37 -2
- package/dist/accounts.js +30 -0
- package/dist/args.js +36 -0
- package/dist/auth-token.js +778 -0
- package/dist/auth.js +290 -0
- package/dist/blog.js +562 -0
- package/dist/browser-links.js +223 -0
- package/dist/captcha-display.js +30 -0
- package/dist/cli.js +5479 -0
- package/dist/client.js +254 -0
- package/dist/config.js +170 -0
- package/dist/createcontext.js +41 -0
- package/dist/crypto.js +139 -0
- package/dist/cube.js +1771 -0
- package/dist/email.js +841 -0
- package/dist/field-preparation.js +185 -0
- package/dist/fileutil.js +20 -0
- package/dist/hrm.js +478 -0
- package/dist/keepalive.js +99 -0
- package/dist/kq.js +280 -0
- package/dist/links.js +34 -0
- package/dist/nodemap.js +242 -0
- package/dist/odoc.js +199 -0
- package/dist/office-tools.js +138 -0
- package/dist/pageopen.js +29 -0
- package/dist/parse.js +177 -0
- package/dist/password-input.js +84 -0
- package/dist/password-login.js +208 -0
- package/dist/querystate.js +38 -0
- package/dist/render.js +21 -0
- package/dist/report.js +50 -0
- package/dist/session-worker.js +54 -0
- package/dist/session.js +280 -0
- package/dist/submissionplan.js +45 -0
- package/dist/submissionstate.js +81 -0
- package/dist/subordinate.js +458 -0
- package/dist/supervise.js +152 -0
- package/dist/todoaskidea.js +54 -0
- package/dist/todobatch.js +172 -0
- package/dist/todocc.js +128 -0
- package/dist/todoforward.js +66 -0
- package/dist/todoreject.js +215 -0
- package/dist/todosubmit.js +325 -0
- package/dist/todotransfer.js +54 -0
- package/dist/util.js +25 -0
- package/dist/wfagent.js +538 -0
- package/dist/wfcreate.js +102 -0
- package/dist/wfform.js +169 -0
- package/dist/wfops.js +40 -0
- package/dist/wfreceiver.js +43 -0
- package/dist/wfstart.js +558 -0
- package/dist/wfsummary.js +89 -0
- package/dist/wfupload.js +78 -0
- package/dist/wfuser.js +96 -0
- package/dist/workflow-runtime.js +151 -0
- package/dist/workflow.js +570 -0
- package/dist/workplan.js +431 -0
- package/package.json +28 -6
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { refreshCookie, mergeResponseCookies, authFailureCode, latchForcedOffline, assertSessionNotForcedOffline } from './session.js';
|
|
2
|
+
|
|
3
|
+
// Browser authentication belongs at the final output boundary. Business URL builders
|
|
4
|
+
// remain pure; this module never calls business APIs or acquires native SSO tokens.
|
|
5
|
+
const ISSUE_PATH = '/api/bdip/assistant/browser-links';
|
|
6
|
+
const OPEN_PATH = '/api/bdip/assistant/open';
|
|
7
|
+
const BATCH_SIZE = 50;
|
|
8
|
+
const CONCURRENCY = 3;
|
|
9
|
+
const MAX_TTL_MS = 60 * 60 * 1000;
|
|
10
|
+
const warnedConfigs = new WeakSet();
|
|
11
|
+
let warnedInvalidConfig = false;
|
|
12
|
+
const PAGE_PATHS = new Set([
|
|
13
|
+
'/wui/index.html', '/spa/workflow/static4form/index.html', '/spa/workflow/static4mobileform/index.html', '/spa/cube/index.html',
|
|
14
|
+
'/spa/email/static/index.html', '/spa/workplan/static/index.html', '/spa/hrm/index_mobx.html',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function plain(targetUrl, status, reason = null) {
|
|
18
|
+
return { targetUrl: targetUrl ?? null, url: targetUrl || null,
|
|
19
|
+
browserLinkStatus: status, expiresAt: null, browserLinkReason: reason };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function configuration(cfg) {
|
|
23
|
+
const raw = String(cfg?.baseUrl ?? '').replace(/\/+$/, '');
|
|
24
|
+
if (!/^https?:\/\//i.test(raw) || /[\s\\]/.test(raw)) throw Error('invalid-base');
|
|
25
|
+
const base = new URL(raw);
|
|
26
|
+
if (base.username || base.password || base.search || base.hash
|
|
27
|
+
|| base.pathname !== (raw.match(/^https?:\/\/[^/?#]+([^?#]*)/i)?.[1] || '/')) throw Error('invalid-base');
|
|
28
|
+
const pagePaths = new Set(PAGE_PATHS);
|
|
29
|
+
// Exact paths relative to the OA context: for baseUrl=https://oa/e9 use
|
|
30
|
+
// /custom/page.jsp, not /e9/custom/page.jsp. CLI extras ADD to defaults;
|
|
31
|
+
// server allowedTargetPaths REPLACES its defaults and must include them.
|
|
32
|
+
// Invalid entries and wildcards never widen access.
|
|
33
|
+
const extras = cfg?.browserLinks?.allowedPaths;
|
|
34
|
+
for (const path of Array.isArray(extras) ? extras : typeof extras === 'string' ? extras.split(',').map(p => p.trim()) : []) {
|
|
35
|
+
if (typeof path !== 'string' || !/^\/(?!\/)/.test(path)
|
|
36
|
+
|| /[\u0000-\u0020\u007f\\%*?#]/.test(path)
|
|
37
|
+
|| /(?:^|\/)(?:api|ssologin|login|logout|download|upload|print|pdf)(?:[/.]|$)/i.test(path)
|
|
38
|
+
|| /\.(?:pdf|zip|xlsx?|docx?|png|jpe?g|gif)$/i.test(path)) continue;
|
|
39
|
+
if (new URL(path, 'https://path.invalid').pathname === path) pagePaths.add(path);
|
|
40
|
+
}
|
|
41
|
+
return { base, context: base.pathname.replace(/\/+$/, ''), endpoint: raw + ISSUE_PATH, pagePaths };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function targetPolicy(target, { base, context, pagePaths }) {
|
|
45
|
+
if (typeof target !== 'string' || !target) return 'unavailable';
|
|
46
|
+
if (!/^https?:\/\//i.test(target) || /[\u0000-\u0020\u007f\\]/.test(target)) return 'excluded';
|
|
47
|
+
let parsed;
|
|
48
|
+
try { parsed = new URL(target); } catch { return 'excluded'; }
|
|
49
|
+
if (parsed.origin !== base.origin || parsed.username || parsed.password
|
|
50
|
+
|| parsed.pathname !== (target.match(/^https?:\/\/[^/?#]+([^?#]*)/i)?.[1] || '/')) return 'excluded';
|
|
51
|
+
if (context && !parsed.pathname.startsWith(context + '/')) return 'excluded';
|
|
52
|
+
const path = parsed.pathname.slice(context.length);
|
|
53
|
+
// Only browser pages are eligible. File/API/print/PDF requests never enter the issuer.
|
|
54
|
+
if (!pagePaths.has(path)) return 'excluded';
|
|
55
|
+
const decoded = (() => { try { return decodeURIComponent(parsed.search + parsed.hash); } catch { return ''; } })();
|
|
56
|
+
if (/(?:[?&#]|^)(?:ssoToken|ticket|token|aiAgentToken|redirect|redirectUrl|url|target|download|upload|print|pdf)=/i.test(decoded)
|
|
57
|
+
|| /(?:\/|[?&#])(?:print|pdf|download|upload)(?:[/?&#=]|$)/i.test(decoded)) return 'excluded';
|
|
58
|
+
return 'eligible';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bridgeUrl(value, conf) {
|
|
62
|
+
if (typeof value !== 'string' || !/^https?:\/\//i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) return false;
|
|
63
|
+
let url;
|
|
64
|
+
try { url = new URL(value); } catch { return false; }
|
|
65
|
+
if (url.origin !== conf.base.origin || url.username || url.password || url.hash
|
|
66
|
+
|| url.pathname !== conf.context + OPEN_PATH
|
|
67
|
+
|| url.pathname !== value.match(/^https?:\/\/[^/?#]+([^?#]*)/i)?.[1]) return false;
|
|
68
|
+
const keys = [...url.searchParams.keys()];
|
|
69
|
+
return keys.length === 1 && keys[0] === 'ticket' && /^[A-Za-z0-9_-]{16,256}$/.test(url.searchParams.get('ticket'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function expiryMillis(value) {
|
|
73
|
+
if (typeof value === 'number') return value;
|
|
74
|
+
if (typeof value !== 'string' || !value) return NaN;
|
|
75
|
+
return /^\d+$/.test(value) ? Number(value) : Date.parse(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function warnOnce(cfg, options) {
|
|
79
|
+
if (cfg && typeof cfg === 'object') {
|
|
80
|
+
if (warnedConfigs.has(cfg)) return;
|
|
81
|
+
warnedConfigs.add(cfg);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (warnedInvalidConfig) return;
|
|
85
|
+
warnedInvalidConfig = true;
|
|
86
|
+
}
|
|
87
|
+
const diagnostic = options.diagnostic ?? ((message) => console.error(message));
|
|
88
|
+
diagnostic('[browser-links] 浏览器免登录链接暂不可用(扩展未部署、Cookie 失效或响应无效);业务结果已保留,链接回退原页面,可能需要登录。');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a batch of final clickable OA page URLs using the current Cookie only.
|
|
93
|
+
* Order/duplicates and hashes are preserved; eligible targets are serialized with
|
|
94
|
+
* URL.href before submission so Unicode matches the server's ASCII URI binding.
|
|
95
|
+
* Failed links retain their original URL. Batches are deduplicated and bounded;
|
|
96
|
+
* nothing is cached across calls or account changes.
|
|
97
|
+
* Failure is data, never a reason to retry a successful business operation.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveBrowserLinks(cfg, targetUrls, options = {}) {
|
|
100
|
+
const targets = Array.from(targetUrls ?? []);
|
|
101
|
+
let conf;
|
|
102
|
+
try { conf = configuration(cfg); } catch {
|
|
103
|
+
if (targets.length) warnOnce(cfg, options);
|
|
104
|
+
return targets.map((target) => plain(target, 'fallback', 'invalid-configuration'));
|
|
105
|
+
}
|
|
106
|
+
// Refresh only local credentials; direct link output must never start a
|
|
107
|
+
// heartbeat or token login. Forced-offline/profile changes remain fallback data.
|
|
108
|
+
let sessionFailure = null;
|
|
109
|
+
try { refreshCookie(cfg); assertSessionNotForcedOffline(cfg); }
|
|
110
|
+
catch (error) {
|
|
111
|
+
sessionFailure = error.code === 'FORCED_OFFLINE' ? 'forced-offline'
|
|
112
|
+
: error.code === 'PROFILE_CHANGED' ? 'profile-changed' : 'session-unavailable';
|
|
113
|
+
}
|
|
114
|
+
const resolved = new Map();
|
|
115
|
+
const pending = [];
|
|
116
|
+
const originals = new Map();
|
|
117
|
+
const store = (target, result) => {
|
|
118
|
+
for (const original of originals.get(target)) {
|
|
119
|
+
resolved.set(original, result.browserLinkStatus === 'sso'
|
|
120
|
+
? result : plain(original, result.browserLinkStatus, result.browserLinkReason));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
for (const target of targets) {
|
|
124
|
+
if (resolved.has(target)) continue;
|
|
125
|
+
const policy = targetPolicy(target, conf);
|
|
126
|
+
if (policy !== 'eligible') resolved.set(target, plain(target, policy, policy === 'excluded' ? 'not-an-oa-browser-page' : 'missing-target'));
|
|
127
|
+
else if (cfg.browserLinks?.enabled === false) resolved.set(target, plain(target, 'disabled', 'disabled-by-configuration'));
|
|
128
|
+
else if (sessionFailure) resolved.set(target, plain(target, 'fallback', sessionFailure));
|
|
129
|
+
else if (!String(cfg.cookie ?? '').trim()) resolved.set(target, plain(target, 'fallback', 'missing-cookie'));
|
|
130
|
+
else {
|
|
131
|
+
// Validate the raw path before URL serialization: canonicalization must
|
|
132
|
+
// never turn forbidden dot segments into an allowed browser page.
|
|
133
|
+
const canonical = new URL(target).href;
|
|
134
|
+
resolved.set(target, null);
|
|
135
|
+
if (!originals.has(canonical)) { originals.set(canonical, []); pending.push(canonical); }
|
|
136
|
+
originals.get(canonical).push(target);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const batches = [];
|
|
140
|
+
for (let i = 0; i < pending.length; i += BATCH_SIZE) batches.push(pending.slice(i, i + BATCH_SIZE));
|
|
141
|
+
let cursor = 0;
|
|
142
|
+
// Capture credentials for this resolution, with no shared ticket cache. A later
|
|
143
|
+
// invocation after identityShift always asks the server for the current identity.
|
|
144
|
+
const cookie = String(cfg.cookie ?? '');
|
|
145
|
+
const credential = { ...cfg, cookie };
|
|
146
|
+
const worker = async () => {
|
|
147
|
+
while (cursor < batches.length) {
|
|
148
|
+
const batch = batches[cursor++];
|
|
149
|
+
let reason = 'issuer-unavailable';
|
|
150
|
+
try {
|
|
151
|
+
// A concurrent batch may already have observed 003/004 for this Cookie.
|
|
152
|
+
assertSessionNotForcedOffline(credential);
|
|
153
|
+
const response = await fetch(conf.endpoint, {
|
|
154
|
+
method: 'POST', redirect: 'error', cache: 'no-store',
|
|
155
|
+
headers: { Cookie: cookie, Accept: 'application/json',
|
|
156
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' },
|
|
157
|
+
body: new URLSearchParams({ targets: JSON.stringify(batch) }),
|
|
158
|
+
signal: AbortSignal.timeout(10000),
|
|
159
|
+
});
|
|
160
|
+
const json = await response.json().catch(() => null);
|
|
161
|
+
const authFailure = authFailureCode(response.status, json);
|
|
162
|
+
if (authFailure === 'FORCED_OFFLINE') {
|
|
163
|
+
// Bind the latch to the Cookie actually sent, and never accept
|
|
164
|
+
// replacement cookies supplied by a forced-offline response.
|
|
165
|
+
latchForcedOffline(cfg, { mode: 'cookie' }, cookie);
|
|
166
|
+
reason = 'forced-offline';
|
|
167
|
+
}
|
|
168
|
+
else if (authFailure === 'SESSION_EXPIRED' || response.status === 403) reason = 'cookie-expired';
|
|
169
|
+
else if (!response.ok) reason = 'issuer-unavailable';
|
|
170
|
+
else {
|
|
171
|
+
const data = json?.data;
|
|
172
|
+
const identity = data && String(data.userId ?? '').trim() && String(data.loginId ?? '').trim();
|
|
173
|
+
if (json?.success === true && identity && Array.isArray(data.links)) {
|
|
174
|
+
assertSessionNotForcedOffline(credential);
|
|
175
|
+
mergeResponseCookies(cfg, response.headers, cookie);
|
|
176
|
+
const rows = new Map();
|
|
177
|
+
const duplicates = new Set();
|
|
178
|
+
for (const row of data.links) {
|
|
179
|
+
if (!batch.includes(row?.targetUrl)) continue;
|
|
180
|
+
if (rows.has(row.targetUrl)) duplicates.add(row.targetUrl);
|
|
181
|
+
rows.set(row.targetUrl, row);
|
|
182
|
+
}
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
for (const target of batch) {
|
|
185
|
+
const row = rows.get(target);
|
|
186
|
+
const expires = expiryMillis(row?.expiresAt);
|
|
187
|
+
if (!duplicates.has(target) && bridgeUrl(row?.url, conf)
|
|
188
|
+
&& Number.isFinite(expires) && expires > now && expires <= now + MAX_TTL_MS) {
|
|
189
|
+
store(target, { targetUrl: target, url: row.url, browserLinkStatus: 'sso',
|
|
190
|
+
expiresAt: new Date(expires).toISOString(), browserLinkReason: null,
|
|
191
|
+
userId: String(data.userId), loginId: String(data.loginId) });
|
|
192
|
+
}
|
|
193
|
+
else store(target, plain(target, 'fallback', 'invalid-link-response'));
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
reason = 'invalid-issuer-response';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
// Never echo remote errors, cookies, or native tokens.
|
|
202
|
+
if (error.code === 'FORCED_OFFLINE') reason = 'forced-offline';
|
|
203
|
+
}
|
|
204
|
+
for (const target of batch) store(target, plain(target, 'fallback', reason));
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, batches.length) }, worker));
|
|
208
|
+
const results = targets.map((target) => ({ ...resolved.get(target) }));
|
|
209
|
+
if (results.some((result) => result.browserLinkStatus === 'fallback')) warnOnce(cfg, options);
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function resolveBrowserLink(cfg, targetUrl, options = {}) {
|
|
214
|
+
return (await resolveBrowserLinks(cfg, [targetUrl], options))[0];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Common Markdown status suffix, using exactly the resolution returned in JSON. */
|
|
218
|
+
export function browserLinkNote(link) {
|
|
219
|
+
if (link?.browserLinkStatus === 'sso') return `(免登录链接有效至 ${link.expiresAt})`;
|
|
220
|
+
if (link?.browserLinkStatus === 'fallback') return '(免登录链接不可用,可能需要登录)';
|
|
221
|
+
if (link?.browserLinkStatus === 'disabled') return '(普通页面,可能需要登录)';
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readCaptchaAnswer } from './password-input.js';
|
|
5
|
+
function invalid() { const e = new Error('验证码图片格式或大小无效;未保存挑战。'); e.code = 'PASSWORD_CAPTCHA_INVALID'; return e; }
|
|
6
|
+
/** Only an ephemeral image is written, never credentials, challenge keys or cookies. */
|
|
7
|
+
export async function promptCaptcha(challenge, { input = process.stdin, readAnswer = readCaptchaAnswer, signal, notify = value => console.error('BDIP_CAPTCHA_REQUIRED ' + JSON.stringify(value)) } = {}) {
|
|
8
|
+
const { image, contentType } = challenge ?? {};
|
|
9
|
+
const mime = String(contentType ?? '').split(';')[0].trim().toLowerCase();
|
|
10
|
+
const png = Buffer.isBuffer(image) && image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]));
|
|
11
|
+
const jpg = Buffer.isBuffer(image) && image[0] === 255 && image[1] === 216 && image[2] === 255;
|
|
12
|
+
const gif = Buffer.isBuffer(image) && /^GIF8[79]a$/.test(image.subarray(0,6).toString('ascii'));
|
|
13
|
+
const extension = mime === 'image/png' && png ? 'png' : mime === 'image/jpeg' && jpg ? 'jpg' : mime === 'image/gif' && gif ? 'gif' : null;
|
|
14
|
+
if (!extension || !image.length || image.length > 1024 * 1024) throw invalid();
|
|
15
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'bdip-captcha-'));
|
|
16
|
+
fs.chmodSync(directory, 0o700);
|
|
17
|
+
const file = path.join(directory, 'captcha.' + extension);
|
|
18
|
+
try {
|
|
19
|
+
fs.writeFileSync(file, image, { mode: 0o600, flag: 'wx' });
|
|
20
|
+
notify({ imagePath: file, contentType: mime, expiresAt: Date.now() + 300000,
|
|
21
|
+
instruction: '展示图片给用户人工填写;保持此进程和 stdin 打开,下一行输入仅含 captcha 的 JSON,不要另开登录进程。' });
|
|
22
|
+
return await readAnswer(input, { timeoutMs: 300000, signal });
|
|
23
|
+
} finally {
|
|
24
|
+
// Only remove the fixed leaf and exact mkdtemp directory created above.
|
|
25
|
+
if (path.basename(directory).startsWith('bdip-captcha-') && path.dirname(file) === directory) {
|
|
26
|
+
try { fs.unlinkSync(file); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
27
|
+
fs.rmdirSync(directory);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|