fix(contribution): dedupe localhost callback submission
This commit is contained in:
@@ -44,6 +44,7 @@
|
|||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
let listenersBound = false;
|
let listenersBound = false;
|
||||||
|
const inFlightCapturedCallbackTasks = new Map();
|
||||||
|
|
||||||
function normalizeString(value = '') {
|
function normalizeString(value = '') {
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
@@ -110,6 +111,29 @@
|
|||||||
return FINAL_STATUSES.has(normalizeContributionStatus(status));
|
return FINAL_STATUSES.has(normalizeContributionStatus(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runCapturedCallbackOnce(callbackUrl, executor) {
|
||||||
|
const normalizedUrl = normalizeString(callbackUrl);
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return Promise.resolve().then(executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTask = inFlightCapturedCallbackTasks.get(normalizedUrl);
|
||||||
|
if (existingTask) {
|
||||||
|
return existingTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = null;
|
||||||
|
task = Promise.resolve()
|
||||||
|
.then(executor)
|
||||||
|
.finally(() => {
|
||||||
|
if (inFlightCapturedCallbackTasks.get(normalizedUrl) === task) {
|
||||||
|
inFlightCapturedCallbackTasks.delete(normalizedUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
inFlightCapturedCallbackTasks.set(normalizedUrl, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeContributionSource(value = '') {
|
function normalizeContributionSource(value = '') {
|
||||||
const normalized = normalizeString(value).toLowerCase();
|
const normalized = normalizeString(value).toLowerCase();
|
||||||
return normalized === CONTRIBUTION_SOURCE_SUB2API
|
return normalized === CONTRIBUTION_SOURCE_SUB2API
|
||||||
@@ -513,42 +537,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCapturedCallback(rawUrl, metadata = {}) {
|
async function handleCapturedCallback(rawUrl, metadata = {}) {
|
||||||
const currentState = await getState();
|
|
||||||
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
|
|
||||||
return currentState;
|
|
||||||
}
|
|
||||||
if (!isContributionCallbackUrl(rawUrl, currentState)) {
|
|
||||||
return currentState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedUrl = normalizeString(rawUrl);
|
const normalizedUrl = normalizeString(rawUrl);
|
||||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
return runCapturedCallbackOnce(normalizedUrl, async () => {
|
||||||
if (
|
const currentState = await getState();
|
||||||
normalizedUrl
|
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
|
||||||
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
return currentState;
|
||||||
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
}
|
||||||
) {
|
if (!isContributionCallbackUrl(normalizedUrl, currentState)) {
|
||||||
return currentState;
|
return currentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
await applyRuntimeUpdates({
|
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||||
contributionCallbackUrl: normalizedUrl,
|
if (
|
||||||
contributionCallbackStatus: 'captured',
|
normalizedUrl
|
||||||
contributionCallbackMessage: buildCallbackMessage('captured'),
|
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
||||||
});
|
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
||||||
|
) {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof addLog === 'function') {
|
await applyRuntimeUpdates({
|
||||||
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
|
contributionCallbackUrl: normalizedUrl,
|
||||||
}
|
contributionCallbackStatus: 'captured',
|
||||||
|
contributionCallbackMessage: buildCallbackMessage('captured'),
|
||||||
try {
|
|
||||||
return await submitContributionCallback(normalizedUrl, {
|
|
||||||
reason: metadata.source || 'navigation',
|
|
||||||
stateOverride: await getState(),
|
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
return getState();
|
if (typeof addLog === 'function') {
|
||||||
}
|
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await submitContributionCallback(normalizedUrl, {
|
||||||
|
reason: metadata.source || 'navigation',
|
||||||
|
stateOverride: await getState(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return getState();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollContributionStatus(options = {}) {
|
async function pollContributionStatus(options = {}) {
|
||||||
@@ -696,7 +722,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTabUpdated(tabId, changeInfo, tab) {
|
function onTabUpdated(tabId, changeInfo, tab) {
|
||||||
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
|
const candidateUrl = normalizeString(changeInfo?.url);
|
||||||
if (!candidateUrl) {
|
if (!candidateUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,6 +510,157 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
|
|||||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted'));
|
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('contribution oauth manager deduplicates concurrent callback captures for the same localhost url', async () => {
|
||||||
|
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||||
|
const globalScope = {};
|
||||||
|
const fetchCalls = [];
|
||||||
|
const broadcasts = [];
|
||||||
|
let submitCallCount = 0;
|
||||||
|
let resolveSubmitRequest = null;
|
||||||
|
let currentState = {
|
||||||
|
contributionMode: true,
|
||||||
|
contributionSource: 'sub2api',
|
||||||
|
contributionTargetGroupName: 'codex号池',
|
||||||
|
contributionSessionId: 'session-001',
|
||||||
|
contributionAuthState: 'oauth-state-001',
|
||||||
|
contributionStatus: 'waiting',
|
||||||
|
contributionCallbackStatus: 'idle',
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||||
|
globalScope,
|
||||||
|
async (url, options = {}) => {
|
||||||
|
fetchCalls.push({ url, options });
|
||||||
|
if (String(url).endsWith('/submit-callback')) {
|
||||||
|
submitCallCount += 1;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
resolveSubmitRequest = resolve;
|
||||||
|
});
|
||||||
|
return createMockResponse(true, 200, {
|
||||||
|
ok: true,
|
||||||
|
session_id: 'session-001',
|
||||||
|
status: 'processing',
|
||||||
|
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(url).includes('/status?')) {
|
||||||
|
return createMockResponse(true, 200, {
|
||||||
|
ok: true,
|
||||||
|
session_id: 'session-001',
|
||||||
|
status: 'processing',
|
||||||
|
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createMockResponse(true, 200, { ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = api.createContributionOAuthManager({
|
||||||
|
addLog: async () => {},
|
||||||
|
broadcastDataUpdate(updates) {
|
||||||
|
broadcasts.push(updates);
|
||||||
|
currentState = { ...currentState, ...updates };
|
||||||
|
},
|
||||||
|
chrome: {
|
||||||
|
tabs: {
|
||||||
|
onUpdated: { addListener() {} },
|
||||||
|
},
|
||||||
|
webNavigation: {
|
||||||
|
onCommitted: { addListener() {} },
|
||||||
|
onHistoryStateUpdated: { addListener() {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getState: async () => currentState,
|
||||||
|
setState: async (updates) => {
|
||||||
|
currentState = { ...currentState, ...updates };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const callbackUrl = 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001';
|
||||||
|
const firstTask = manager.handleCapturedCallback(callbackUrl, { source: 'tabs.onUpdated' });
|
||||||
|
const secondTask = manager.handleCapturedCallback(callbackUrl, { source: 'webNavigation.onCommitted' });
|
||||||
|
|
||||||
|
for (let round = 0; round < 10 && submitCallCount === 0; round += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(submitCallCount, 1);
|
||||||
|
assert.equal(
|
||||||
|
broadcasts.filter((entry) => entry.contributionCallbackStatus === 'captured').length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(typeof resolveSubmitRequest, 'function');
|
||||||
|
resolveSubmitRequest();
|
||||||
|
const [firstState, secondState] = await Promise.all([firstTask, secondTask]);
|
||||||
|
|
||||||
|
assert.equal(firstState.contributionCallbackStatus, 'submitted');
|
||||||
|
assert.equal(secondState.contributionCallbackStatus, 'submitted');
|
||||||
|
assert.equal(fetchCalls.filter((call) => String(call.url).endsWith('/submit-callback')).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('contribution oauth manager ignores tabs.onUpdated events without a real url change', async () => {
|
||||||
|
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||||
|
const globalScope = {};
|
||||||
|
const fetchCalls = [];
|
||||||
|
const addedListeners = {};
|
||||||
|
let currentState = {
|
||||||
|
contributionMode: true,
|
||||||
|
contributionSource: 'sub2api',
|
||||||
|
contributionTargetGroupName: 'codex号池',
|
||||||
|
contributionSessionId: 'session-001',
|
||||||
|
contributionAuthState: 'oauth-state-001',
|
||||||
|
contributionStatus: 'waiting',
|
||||||
|
contributionCallbackStatus: 'idle',
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||||
|
globalScope,
|
||||||
|
async (url, options = {}) => {
|
||||||
|
fetchCalls.push({ url, options });
|
||||||
|
return createMockResponse(true, 200, { ok: true, status: 'processing' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const manager = api.createContributionOAuthManager({
|
||||||
|
addLog: async () => {},
|
||||||
|
broadcastDataUpdate(updates) {
|
||||||
|
currentState = { ...currentState, ...updates };
|
||||||
|
},
|
||||||
|
chrome: {
|
||||||
|
tabs: {
|
||||||
|
onUpdated: {
|
||||||
|
addListener(listener) {
|
||||||
|
addedListeners.onTabUpdated = listener;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webNavigation: {
|
||||||
|
onCommitted: { addListener() {} },
|
||||||
|
onHistoryStateUpdated: { addListener() {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getState: async () => currentState,
|
||||||
|
setState: async (updates) => {
|
||||||
|
currentState = { ...currentState, ...updates };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.ensureCallbackListeners();
|
||||||
|
assert.equal(typeof addedListeners.onTabUpdated, 'function');
|
||||||
|
|
||||||
|
addedListeners.onTabUpdated(
|
||||||
|
88,
|
||||||
|
{ status: 'complete' },
|
||||||
|
{ url: 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001' }
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(fetchCalls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => {
|
test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => {
|
||||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||||
const globalScope = {};
|
const globalScope = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user