fix(settings): make DingTalk QR sessions retryable

This commit is contained in:
姚章浩
2026-09-14 23:45:18 +08:00
parent 64d0883e30
commit f82283abd4
+48 -21
View File
@@ -27,20 +27,25 @@ function redacted(s) {
}
function scheduleCleanup(s) {
setTimeout(() => { closeSession(s.id); }, Math.max(0, s.expiresAt - Date.now())).unref?.();
if (s.cleanupTimer) clearTimeout(s.cleanupTimer);
s.cleanupTimer = setTimeout(() => { void closeSession(s.id); }, Math.max(0, s.expiresAt - Date.now()));
s.cleanupTimer.unref?.();
}
function closeSession(id) {
async function closeSession(id) {
const s = sessions.get(id);
if (!s) return;
s.state = "closed";
sessions.delete(id);
s.context?.close().catch(() => {});
if (s.cleanupTimer) clearTimeout(s.cleanupTimer);
if (s.regrabTimer) clearInterval(s.regrabTimer);
const context = s.context;
s.context = null;
if (context) await context.close().catch(() => {});
}
function evictOld() {
const live = [...sessions.values()].sort((a, b) => a.createdAt - b.createdAt);
while (live.length > 3) closeSession(live.shift().id);
async function closeAllSessions() {
await Promise.all([...sessions.keys()].map((id) => closeSession(id)));
}
// ---------- capture ----------
@@ -53,11 +58,11 @@ function loadCapture() {
} catch { return null; }
}
function writeCapture(headers) {
function writeCapture(headers, space) {
const payload = {
capturedAt: new Date().toISOString(),
method: "GET",
url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${VERIFY_SPACE}`,
url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${space}`,
headers,
};
// The scanner reads this file through a single-file bind mount: replacing
@@ -161,9 +166,12 @@ async function startSession(id, space) {
let capturedHeaders = null;
const finish = async (headers) => {
const probe = await boxApi(headers, `/box/api/v2/dentry/list?dentryUuid=${VERIFY_SPACE}&pageSize=1&orderType=SORT_KEY&sortType=desc&listDentrySource=2&withParentAncestors=true`);
// Validate the space the operator actually opened. Validating against an
// unrelated default space rejects perfectly good logins when that account
// has no access to the old/default knowledge base.
const probe = await boxApi(headers, `/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(space)}&pageSize=1&orderType=SORT_KEY&sortType=desc&listDentrySource=2&withParentAncestors=true`);
if (probe.status === 200 && probe.body && probe.body.data && probe.body.data.spaceProfile) {
writeCapture(headers);
writeCapture(headers, space);
s.state = "success"; s.error = null;
} else {
s.state = "failed"; s.error = "capture_validation_failed";
@@ -172,25 +180,30 @@ async function startSession(id, space) {
scheduleCleanup(s);
};
const captureRequest = async (request) => {
const url = request.url();
if (!url.includes("dingtalk.com")) return;
// Playwright request.headers() intentionally omits security-related
// headers on some Chromium builds, notably Cookie. allHeaders() is the
// complete API and is required for the capture consumed by the scanner.
const captureResponse = async (response) => {
if (capturedHeaders || response.status() !== 200) return;
const request = response.request();
let url;
try { url = new URL(response.url()); } catch { return; }
// Capture only a successful Box directory response. The first Alidocs
// request after OAuth may still carry a stale token; freezing that request
// caused the scan to remain failed even after a later valid token arrived.
if (url.hostname !== "alidocs.dingtalk.com" || url.pathname !== "/box/api/v2/dentry/list") return;
const h = await request.allHeaders().catch(() => request.headers());
if (capturedHeaders) return;
if (!h["a-token"] || !h.cookie) return;
let body;
try { body = await response.json(); } catch { return; }
if (!body?.data?.spaceProfile) return;
capturedHeaders = { ...h };
delete capturedHeaders["content-length"];
delete capturedHeaders["accept-encoding"];
delete capturedHeaders["host"];
s.state = s.state === "qr" ? "scanned" : "reusing";
finish(capturedHeaders);
void finish(capturedHeaders);
};
// Login may redirect the original tab or open another page. Listening on
// the whole context keeps credential capture alive across both paths.
context.on("request", captureRequest);
context.on("response", captureResponse);
// Read the QR directly from DingTalk's canvas. Do NOT screenshot the DOM:
// Playwright element screenshots on a high-DPR page can apply the crop in
@@ -233,6 +246,16 @@ async function startSession(id, space) {
await page.goto(SCAN_URL(space), { waitUntil: "domcontentloaded", timeout: 45000 }).catch(() => {});
if (page.url().includes("login.dingtalk.com") && !capturedHeaders) {
// The current DingTalk login UI leaves QR "自动登录" unchecked. Without
// it, the phone can accept the scan while the waiting web challenge stays
// on the login page. Select only the visible QR-mode checkbox.
await page.evaluate(() => {
const target = [...document.querySelectorAll('.module-qrcode-op-item')].find((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && (el.textContent || '').trim() === '自动登录';
});
if (target?.querySelector('.dingtalk-login-icon-checkbox-undone')) target.click();
}).catch(() => {});
// The canvas is rendered after DOMContentLoaded. Keep the public state at
// starting until a real QR exists so the UI shows its generation loader,
// never an empty "please scan" panel.
@@ -260,6 +283,7 @@ async function startSession(id, space) {
}
}, 8000);
regrab.unref?.();
s.regrabTimer = regrab;
}
}
@@ -276,7 +300,10 @@ const server = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && url.pathname === "/healthz") return send(200, { status: "ok", state_configured: Boolean(STATE_FILE) });
if (req.method === "POST" && url.pathname === "/sessions") {
evictOld();
// One persistent profile must have exactly one owner. Close and await
// every prior context before creating the next challenge; this makes
// cancel/retry deterministic and prevents stale QR/polling state.
await closeAllSessions();
const body = await readJson();
const space = extractSpace(body.space) || VERIFY_SPACE;
const id = crypto.randomBytes(9).toString("hex");
@@ -289,7 +316,7 @@ const server = http.createServer(async (req, res) => {
const s = sessions.get(sessionMatch[1]);
if (!s) return send(404, { error: "session_not_found" });
if (req.method === "GET") return send(200, redacted(s));
if (req.method === "DELETE") { closeSession(sessionMatch[1]); return send(200, { status: "closed" }); }
if (req.method === "DELETE") { await closeSession(sessionMatch[1]); return send(200, { status: "closed" }); }
}
if (req.method === "POST" && (url.pathname === "/spaces/resolve" || url.pathname === "/spaces/add")) {
const body = await readJson();