diff --git a/services/dingtalk-login/server.mjs b/services/dingtalk-login/server.mjs index fde437c..a751083 100644 --- a/services/dingtalk-login/server.mjs +++ b/services/dingtalk-login/server.mjs @@ -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. diff --git a/web/src/DingtalkSessionControl.tsx b/web/src/DingtalkSessionControl.tsx index c125082..88b543f 100644 --- a/web/src/DingtalkSessionControl.tsx +++ b/web/src/DingtalkSessionControl.tsx @@ -12,11 +12,12 @@ const finished = new Set(['success', 'expired', 'failed', 'closed']) interface DingtalkSessionControlProps { autoStart?: boolean hideTrigger?: boolean + space?: string onSuccess?: () => void onClose?: () => void } -export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, onSuccess, onClose }: DingtalkSessionControlProps = {}) { +export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, space, onSuccess, onClose }: DingtalkSessionControlProps = {}) { const [session, setSession] = useState(null) const [failure, setFailure] = useState(null) const [starting, setStarting] = useState(false) @@ -51,7 +52,7 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, setOpen(true); setStarting(true); setFailure(null); setSession(null); setLastSuccess(false) successHandledRef.current = false try { - const fresh = await startDingtalkSession() + const fresh = await startDingtalkSession(space) idRef.current = fresh.id setSession(fresh) } catch (cause) { @@ -59,7 +60,7 @@ export function DingtalkSessionControl({ autoStart = false, hideTrigger = false, } finally { setStarting(false) } - }, []) + }, [space]) useEffect(() => { if (!autoStart || autoStartedRef.current) return diff --git a/web/src/DingtalkSpaceRegistrar.tsx b/web/src/DingtalkSpaceRegistrar.tsx index 3ede276..477d937 100644 --- a/web/src/DingtalkSpaceRegistrar.tsx +++ b/web/src/DingtalkSpaceRegistrar.tsx @@ -59,6 +59,7 @@ export function DingtalkSpaceRegistrar({ onRegistered }: { onRegistered: () => v {loginRequired ? setLoginRequired(false)} /> : null} diff --git a/web/src/dingtalkSession.test.ts b/web/src/dingtalkSession.test.ts index 63c8eb7..db4da20 100644 --- a/web/src/dingtalkSession.test.ts +++ b/web/src/dingtalkSession.test.ts @@ -11,8 +11,10 @@ describe('DingTalk session client', () => { }), { status: 201 })) vi.stubGlobal('fetch', fetchMock) - await expect(startDingtalkSession()).resolves.toMatchObject({ id: 'session-1', state: 'starting' }) + const target = 'https://alidocs.dingtalk.com/i/spaces/9JOGOQneRgkneX4Q/overview' + await expect(startDingtalkSession(target)).resolves.toMatchObject({ id: 'session-1', state: 'starting' }) expect(fetchMock).toHaveBeenCalledWith('/api/v1/dingtalk-session', expect.objectContaining({ method: 'POST' })) + expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ space: target }) }) it('preserves login_required as a machine-readable error for automatic QR fallback', async () => {