361 lines
15 KiB
JavaScript
361 lines
15 KiB
JavaScript
// DingTalk login sidecar for the knowledge governance platform.
|
|
// Drives a persistent headless Chromium for scan-code login, and uses the
|
|
// resulting capture (a-token + cookie headers, exactly what the scanner
|
|
// replays) to resolve and register knowledge-base spaces. Tokens never leave
|
|
// this container except inside the capture file.
|
|
import { chromium } from "playwright";
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import crypto from "node:crypto";
|
|
|
|
const PORT = Number(process.env.PORT || 8091);
|
|
const PROFILE_DIR = process.env.PROFILE_DIR || "/profile";
|
|
const CAPTURE_FILE = process.env.CAPTURE_FILE || "/secrets/dingtalk_capture.json";
|
|
const STATE_FILE = process.env.DINGTALK_STATE_FILE || "";
|
|
const VERIFY_SPACE = process.env.VERIFY_SPACE || "9JOGO6332vlBnm4Q";
|
|
const QR_TTL_MS = Number(process.env.QR_TTL_MS || 5 * 60 * 1000);
|
|
const SCAN_URL = (space) => `https://alidocs.dingtalk.com/i/spaces/${space}/overview`;
|
|
|
|
const sessions = new Map();
|
|
const resolvedSpaces = new Map();
|
|
|
|
function redacted(s) {
|
|
return {
|
|
id: s.id, state: s.state, created_at: s.createdAt, expires_at: s.expiresAt,
|
|
...(s.error ? { error: s.error } : {}),
|
|
...(s.qrPng ? { qr_png: s.qrPng } : {}),
|
|
};
|
|
}
|
|
|
|
function scheduleCleanup(s) {
|
|
if (s.cleanupTimer) clearTimeout(s.cleanupTimer);
|
|
s.cleanupTimer = setTimeout(() => { void closeSession(s.id); }, Math.max(0, s.expiresAt - Date.now()));
|
|
s.cleanupTimer.unref?.();
|
|
}
|
|
|
|
async function closeSession(id) {
|
|
const s = sessions.get(id);
|
|
if (!s) return;
|
|
s.state = "closed";
|
|
sessions.delete(id);
|
|
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(() => {});
|
|
}
|
|
|
|
async function closeAllSessions() {
|
|
await Promise.all([...sessions.keys()].map((id) => closeSession(id)));
|
|
}
|
|
|
|
// ---------- capture ----------
|
|
|
|
function loadCapture() {
|
|
try {
|
|
const capture = JSON.parse(fs.readFileSync(CAPTURE_FILE, "utf8"));
|
|
const h = capture.headers || {};
|
|
return h["a-token"] ? h : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
function writeCapture(headers, rootDentryUuid) {
|
|
const payload = {
|
|
capturedAt: new Date().toISOString(),
|
|
method: "GET",
|
|
url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${rootDentryUuid}`,
|
|
headers,
|
|
};
|
|
// The scanner reads this file through a single-file bind mount: replacing
|
|
// the file (rename) would swap the inode and the reader would keep seeing
|
|
// the old session. Write in place instead.
|
|
const fd = fs.openSync(CAPTURE_FILE, "w", 0o600);
|
|
fs.writeFileSync(fd, JSON.stringify(payload, null, 2));
|
|
fs.fsyncSync(fd);
|
|
fs.closeSync(fd);
|
|
}
|
|
|
|
// ---------- box API via the captured browser headers ----------
|
|
|
|
async function boxApi(headers, path) {
|
|
const res = await fetch("https://alidocs.dingtalk.com" + path, {
|
|
headers: {
|
|
accept: "application/json",
|
|
"a-token": headers["a-token"],
|
|
"x-xsrf-token": headers["x-xsrf-token"] || "",
|
|
cookie: headers.cookie || "",
|
|
"user-agent": headers["user-agent"] || "Mozilla/5.0",
|
|
referer: "https://alidocs.dingtalk.com/",
|
|
},
|
|
});
|
|
let body = null;
|
|
try { body = await res.json(); } catch { /* non-json */ }
|
|
return { status: res.status, body };
|
|
}
|
|
|
|
function extractSpace(value) {
|
|
const text = String(value || "").trim();
|
|
const m = text.match(/\/spaces\/([A-Za-z0-9_-]+)/) || (/^[A-Za-z0-9_-]{8,64}$/.test(text) ? [null, text] : null);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
async function resolveSpace(spaceId) {
|
|
const headers = loadCapture();
|
|
if (!headers) return { error: "login_required" };
|
|
let known = resolvedSpaces.get(spaceId);
|
|
if (!known && STATE_FILE) {
|
|
try {
|
|
const roots = loadState().roots || {};
|
|
const entry = Object.values(roots).find((root) => root.space_id === spaceId);
|
|
if (entry?.configured_root_id && entry?.live_space_name) {
|
|
known = { space_id: spaceId, name: entry.live_space_name, root: entry.configured_root_id };
|
|
}
|
|
} catch { /* a browser session can rediscover it */ }
|
|
}
|
|
// A space URL contains the space id, while dentry/list accepts only the root
|
|
// dentry UUID. Unknown spaces must be opened in Chromium once to discover
|
|
// that mapping; returning login_required reuses the same recoverable modal.
|
|
if (!known) return { error: "login_required" };
|
|
const { status, body } = await boxApi(headers,
|
|
`/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(known.root)}&withParentAncestors=true&orderType=SORT_KEY&sortType=desc&listDentrySource=2&pageSize=5`);
|
|
if (status === 401 || (body && body.code === "52600001")) return { error: "login_required" };
|
|
const sp = body && body.data && body.data.spaceProfile;
|
|
if (status !== 200 || !sp || String(sp.id) !== spaceId || !sp.rootDentryUuid || !sp.name) return { error: "space_not_found", http_status: status };
|
|
const resolved = { space_id: spaceId, name: String(sp.name).slice(0, 120), root: String(sp.rootDentryUuid) };
|
|
resolvedSpaces.set(spaceId, resolved);
|
|
return resolved;
|
|
}
|
|
|
|
function loadState() {
|
|
if (!STATE_FILE) throw new Error("state file not configured");
|
|
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
}
|
|
|
|
async function addSpace(spaceId, label) {
|
|
const resolved = await resolveSpace(spaceId);
|
|
if (resolved.error) return resolved;
|
|
const state = loadState();
|
|
const roots = state.roots || (state.roots = {});
|
|
const existing = Object.entries(roots).find(([, r]) => r.configured_root_id === resolved.root);
|
|
const rootKey = /^[a-z][a-z0-9_-]{1,31}$/.test(label || "") ? label : existing ? existing[0] : "space-" + crypto.createHash("sha256").update(spaceId).digest("hex").slice(0, 10);
|
|
const clash = Object.entries(roots).find(([k, r]) => r.live_space_name === resolved.name && k !== rootKey);
|
|
if (clash) return { error: "name_conflict", message: `已登记同名空间「${resolved.name}」` };
|
|
if (!roots[rootKey]) {
|
|
roots[rootKey] = {
|
|
configured_root_id: resolved.root,
|
|
space_id: resolved.space_id,
|
|
// Seed the live name so the refresh job matches this root immediately;
|
|
// the scanner confirms/overwrites it from the same spaceProfile.
|
|
live_space_name: resolved.name,
|
|
queue: [resolved.root],
|
|
scanned_folders: [],
|
|
nodes: {},
|
|
errors: [],
|
|
};
|
|
} else {
|
|
roots[rootKey].live_space_name = resolved.name;
|
|
roots[rootKey].configured_root_id = resolved.root;
|
|
roots[rootKey].space_id = resolved.space_id;
|
|
}
|
|
// The inventory state is reached through a DIRECTORY bind mount: readers
|
|
// open it by path, so an atomic temp+rename swap is safe and avoids
|
|
// exposing half-written JSON to a refresh that starts mid-write.
|
|
const tmp = STATE_FILE + ".tmp";
|
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
fs.renameSync(tmp, STATE_FILE);
|
|
return { key: rootKey, name: resolved.name, root: resolved.root, reused: Boolean(existing) };
|
|
}
|
|
|
|
// ---------- login sessions ----------
|
|
|
|
async function startSession(id, space) {
|
|
const s = sessions.get(id);
|
|
if (!s) return;
|
|
let context;
|
|
try {
|
|
context = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
headless: true, viewport: { width: 1280, height: 900 }, locale: "zh-CN",
|
|
deviceScaleFactor: 3, // crisp QR: canvas screenshots inherit DPR
|
|
});
|
|
} catch {
|
|
s.state = "failed"; s.error = "browser_launch_failed"; return;
|
|
}
|
|
s.context = context;
|
|
const page = context.pages()[0] ?? await context.newPage();
|
|
|
|
let capturedHeaders = null;
|
|
const finish = (headers, profile) => {
|
|
const resolved = {
|
|
space_id: String(profile.id),
|
|
name: String(profile.name).slice(0, 120),
|
|
root: String(profile.rootDentryUuid),
|
|
};
|
|
resolvedSpaces.set(space, resolved);
|
|
writeCapture(headers, resolved.root);
|
|
s.state = "success"; s.error = null;
|
|
s.expiresAt = Math.max(s.expiresAt, Date.now() + 30000);
|
|
scheduleCleanup(s);
|
|
};
|
|
|
|
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 (!h["a-token"] || !h.cookie) return;
|
|
let body;
|
|
try { body = await response.json(); } catch { return; }
|
|
const profile = body?.data?.spaceProfile;
|
|
if (!profile || String(profile.id) !== space || !profile.name || !profile.rootDentryUuid) return;
|
|
capturedHeaders = { ...h };
|
|
delete capturedHeaders["content-length"];
|
|
delete capturedHeaders["accept-encoding"];
|
|
delete capturedHeaders["host"];
|
|
s.state = s.state === "qr" ? "scanned" : "reusing";
|
|
finish(capturedHeaders, profile);
|
|
};
|
|
// Login may redirect the original tab or open another page. Listening on
|
|
// the whole context keeps credential capture alive across both paths.
|
|
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
|
|
// device pixels twice, producing exactly the clipped page thumbnail the
|
|
// user reported. canvas.toDataURL() returns the complete PNG pixels.
|
|
const grabQR = async () => {
|
|
try {
|
|
return await page.evaluate(() => {
|
|
let best = null, bestDark = 0;
|
|
for (const el of document.querySelectorAll('canvas')) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < 100 || r.width > 460 || Math.abs(r.width - r.height) > 24) continue;
|
|
try {
|
|
const pixels = el.getContext('2d').getImageData(0, 0, el.width, el.height).data;
|
|
let dark = 0;
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
if (pixels[i + 3] > 0 && pixels[i] + pixels[i + 1] + pixels[i + 2] < 384) dark++;
|
|
}
|
|
if (dark > bestDark) { best = el; bestDark = dark; }
|
|
} catch { /* unreadable canvas: not suitable */ }
|
|
}
|
|
if (!best || bestDark < 500) return null;
|
|
// DingTalk's QR canvas has a transparent background and no guaranteed
|
|
// quiet zone. Composite it onto white with 20px padding so phone
|
|
// scanners and ordinary QR decoders get a complete code.
|
|
const padding = 20;
|
|
const out = document.createElement('canvas');
|
|
out.width = best.width + padding * 2;
|
|
out.height = best.height + padding * 2;
|
|
const ctx = out.getContext('2d');
|
|
ctx.fillStyle = '#fff';
|
|
ctx.fillRect(0, 0, out.width, out.height);
|
|
ctx.drawImage(best, padding, padding);
|
|
const url = out.toDataURL('image/png');
|
|
return url.startsWith('data:image/png;base64,') ? url.slice('data:image/png;base64,'.length) : null;
|
|
});
|
|
} catch { return null; }
|
|
};
|
|
|
|
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.
|
|
const qrReadyDeadline = Date.now() + 20000;
|
|
while (!capturedHeaders && !s.qrPng && Date.now() < qrReadyDeadline) {
|
|
s.qrPng = await grabQR();
|
|
if (!s.qrPng) await new Promise((resolve) => setTimeout(resolve, 400));
|
|
}
|
|
if (capturedHeaders) return;
|
|
if (!s.qrPng) {
|
|
s.state = "failed";
|
|
s.error = "qr_generation_failed";
|
|
scheduleCleanup(s);
|
|
return;
|
|
}
|
|
s.state = "qr";
|
|
const deadline = Date.now() + QR_TTL_MS;
|
|
const regrab = setInterval(async () => {
|
|
// Keep the freshest frame while waiting for the scan.
|
|
if (capturedHeaders) return;
|
|
try { const png = await grabQR(); if (png) s.qrPng = png; } catch { /* ignore */ }
|
|
if (Date.now() > deadline) {
|
|
clearInterval(regrab);
|
|
if (!capturedHeaders) { s.state = "expired"; scheduleCleanup(s); }
|
|
}
|
|
}, 8000);
|
|
regrab.unref?.();
|
|
s.regrabTimer = regrab;
|
|
}
|
|
}
|
|
|
|
// ---------- HTTP ----------
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
|
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
const readJson = async () => {
|
|
let body = "";
|
|
for await (const chunk of req) { body += chunk; if (body.length > 8192) break; }
|
|
try { return JSON.parse(body || "{}"); } catch { return {}; }
|
|
};
|
|
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") {
|
|
// 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");
|
|
sessions.set(id, { id, state: "starting", space, createdAt: Date.now(), expiresAt: Date.now() + QR_TTL_MS + 60000 });
|
|
startSession(id, space);
|
|
return send(201, redacted(sessions.get(id)));
|
|
}
|
|
const sessionMatch = url.pathname.match(/^\/sessions\/([\w-]+)$/);
|
|
if (sessionMatch) {
|
|
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") { 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();
|
|
const space = extractSpace(body.url || body.space);
|
|
if (!space) return send(400, { error: "invalid_space_url" });
|
|
if (url.pathname.endsWith("resolve")) {
|
|
const r = await resolveSpace(space);
|
|
return r.error ? send(r.error === "login_required" ? 409 : 404, r) : send(200, r);
|
|
}
|
|
const r = await addSpace(space, body.label);
|
|
if (r.error) {
|
|
const code = r.error === "login_required" ? 409 : r.error === "name_conflict" ? 409 : 404;
|
|
return send(code, r);
|
|
}
|
|
return send(200, r);
|
|
}
|
|
} catch (e) {
|
|
return send(500, { error: "sidecar_internal" });
|
|
}
|
|
send(404, { error: "not_found" });
|
|
});
|
|
|
|
server.listen(PORT, "0.0.0.0", () => console.log(`dingtalk-login sidecar on :${PORT}`));
|