|
|
|
@@ -17,6 +17,7 @@ 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 {
|
|
|
|
@@ -58,11 +59,11 @@ function loadCapture() {
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeCapture(headers, space) {
|
|
|
|
|
function writeCapture(headers, rootDentryUuid) {
|
|
|
|
|
const payload = {
|
|
|
|
|
capturedAt: new Date().toISOString(),
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${space}`,
|
|
|
|
|
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
|
|
|
|
@@ -101,12 +102,28 @@ function extractSpace(value) {
|
|
|
|
|
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(spaceId)}&withParentAncestors=true&orderType=SORT_KEY&sortType=desc&listDentrySource=2&pageSize=5`);
|
|
|
|
|
`/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 || !sp.rootDentryUuid || !sp.name) return { error: "space_not_found", http_status: status };
|
|
|
|
|
return { space_id: spaceId, name: String(sp.name).slice(0, 120), root: String(sp.rootDentryUuid) };
|
|
|
|
|
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() {
|
|
|
|
@@ -126,6 +143,7 @@ async function addSpace(spaceId, label) {
|
|
|
|
|
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,
|
|
|
|
@@ -137,6 +155,7 @@ async function addSpace(spaceId, label) {
|
|
|
|
|
} 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
|
|
|
|
@@ -165,17 +184,15 @@ async function startSession(id, space) {
|
|
|
|
|
const page = context.pages()[0] ?? await context.newPage();
|
|
|
|
|
|
|
|
|
|
let capturedHeaders = null;
|
|
|
|
|
const finish = async (headers) => {
|
|
|
|
|
// 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, space);
|
|
|
|
|
s.state = "success"; s.error = null;
|
|
|
|
|
} else {
|
|
|
|
|
s.state = "failed"; s.error = "capture_validation_failed";
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
@@ -193,13 +210,14 @@ async function startSession(id, space) {
|
|
|
|
|
if (!h["a-token"] || !h.cookie) return;
|
|
|
|
|
let body;
|
|
|
|
|
try { body = await response.json(); } catch { return; }
|
|
|
|
|
if (!body?.data?.spaceProfile) 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";
|
|
|
|
|
void finish(capturedHeaders);
|
|
|
|
|
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.
|
|
|
|
|