fix(settings): make DingTalk QR sessions retryable
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user