fix: harden CPA auth flow with API callback/state checks
Move CPA oauth url/callback handling to management API first, persist state/origin runtime keys, and fail fast on invalid callback or missing management auth to reduce flaky panel-driven auth failures.
This commit is contained in:
@@ -8,11 +8,12 @@
|
||||
|
||||
一百五十个号,一个401:
|
||||
|
||||
进官网,获取最新交流群:<https://apikey.qzz.io/>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="100%">
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/images/交流群.jpg" alt="QQ交流群,便于大家交流" width="100%" />
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/images/十轮自动.png" alt="最新版本运行日志" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -74,7 +75,6 @@
|
||||
- 至少准备一种验证码接收方式:
|
||||
- DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发
|
||||
- Cloudflare 自定义域邮箱前缀 + QQ / 163 / Inbucket 转发
|
||||
- iCloud Hide My Email,可选择直接从 iCloud 收件箱收码,或转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码
|
||||
- 手动填写一个可收信邮箱
|
||||
- 如果使用 `QQ` / `163` / `163 VIP` / `126` / `Inbucket`,对应页面需要提前能正常打开
|
||||
|
||||
@@ -592,7 +592,6 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
||||
支持:
|
||||
|
||||
- `Hotmail`(远程服务 / 本地助手)
|
||||
- `iCloud` 收件箱,或 iCloud Hide My Email 转发到 QQ / 163 / 163 VIP / 126 / Gmail 后收码
|
||||
- `content/qq-mail.js`
|
||||
- `content/mail-163.js`(163 / 163 VIP / 126)
|
||||
- `content/inbucket-mail.js`
|
||||
@@ -774,7 +773,7 @@ content/utils.js 通用工具:等待元素、点击、日志、停
|
||||
content/vps-panel.js CPA 面板步骤:内部 OAuth 刷新 / Step 10
|
||||
content/signup-page.js ChatGPT 官网 + OpenAI 注册/登录页步骤:Step 1 / 2 / 3 / 5 / 7 / 9
|
||||
hotmail-utils.js Hotmail 收信相关通用辅助
|
||||
mail-provider-utils.js 网页邮箱 provider 与 iCloud 转发收码配置辅助
|
||||
mail-provider-utils.js 网页邮箱 provider 配置辅助
|
||||
content/duck-mail.js Duck 邮箱自动获取
|
||||
content/qq-mail.js QQ 邮箱验证码轮询
|
||||
content/mail-163.js 163 / 163 VIP / 126 邮箱验证码轮询
|
||||
|
||||
+212
-1780
File diff suppressed because it is too large
Load Diff
@@ -3,19 +3,13 @@
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() {
|
||||
const API_BASE_URL = 'https://apikey.qzz.io/oauth/api';
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'expired', 'error']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
|
||||
const CALLBACK_FINAL_STATUSES = new Set(['submitted']);
|
||||
const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']);
|
||||
const CONTRIBUTION_SOURCE_CPA = 'cpa';
|
||||
const CONTRIBUTION_SOURCE_SUB2API = 'sub2api';
|
||||
const CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME = 'codex号池';
|
||||
const CONTRIBUTION_SUB2API_PLUS_GROUP_NAME = 'openai-plus';
|
||||
|
||||
const RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSource: CONTRIBUTION_SOURCE_SUB2API,
|
||||
contributionTargetGroupName: CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -44,7 +38,6 @@
|
||||
} = deps;
|
||||
|
||||
let listenersBound = false;
|
||||
const inFlightCapturedCallbackTasks = new Map();
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
@@ -71,6 +64,9 @@
|
||||
case 'auto_rejected':
|
||||
case 'rejected':
|
||||
return 'auto_rejected';
|
||||
case 'manual_review_required':
|
||||
case 'manual_review':
|
||||
return 'manual_review_required';
|
||||
case 'expired':
|
||||
case 'timeout':
|
||||
return 'expired';
|
||||
@@ -111,63 +107,6 @@
|
||||
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 = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
return normalized === CONTRIBUTION_SOURCE_SUB2API
|
||||
? CONTRIBUTION_SOURCE_SUB2API
|
||||
: CONTRIBUTION_SOURCE_CPA;
|
||||
}
|
||||
|
||||
function resolveContributionRouting(state = {}) {
|
||||
const currentStatus = normalizeContributionStatus(state.contributionStatus);
|
||||
const currentSource = normalizeContributionSource(state.contributionSource);
|
||||
const hasActiveSession = Boolean(
|
||||
normalizeString(state.contributionSessionId)
|
||||
&& currentStatus
|
||||
&& !FINAL_STATUSES.has(currentStatus)
|
||||
);
|
||||
|
||||
if (hasActiveSession) {
|
||||
return {
|
||||
source: currentSource,
|
||||
targetGroupName: currentSource === CONTRIBUTION_SOURCE_SUB2API
|
||||
? (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME)
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
const source = CONTRIBUTION_SOURCE_SUB2API;
|
||||
return {
|
||||
source,
|
||||
targetGroupName: Boolean(state.plusModeEnabled)
|
||||
? CONTRIBUTION_SUB2API_PLUS_GROUP_NAME
|
||||
: (normalizeString(state.contributionTargetGroupName) || CONTRIBUTION_SUB2API_DEFAULT_GROUP_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
function getStatusLabel(status = '') {
|
||||
switch (normalizeContributionStatus(status)) {
|
||||
case 'started':
|
||||
@@ -175,11 +114,13 @@
|
||||
case 'waiting':
|
||||
return '等待提交回调';
|
||||
case 'processing':
|
||||
return '已提交回调,等待服务端确认';
|
||||
return '已提交回调,等待 CPA 确认';
|
||||
case 'auto_approved':
|
||||
return '贡献成功,服务端已确认';
|
||||
return '贡献成功,CPA 已确认';
|
||||
case 'auto_rejected':
|
||||
return '贡献未通过确认';
|
||||
case 'manual_review_required':
|
||||
return '已提交,等待人工处理';
|
||||
case 'expired':
|
||||
return '贡献会话已超时';
|
||||
case 'error':
|
||||
@@ -537,44 +478,42 @@
|
||||
}
|
||||
|
||||
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);
|
||||
return runCapturedCallbackOnce(normalizedUrl, async () => {
|
||||
const currentState = await getState();
|
||||
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
|
||||
return currentState;
|
||||
}
|
||||
if (!isContributionCallbackUrl(normalizedUrl, currentState)) {
|
||||
return currentState;
|
||||
}
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (
|
||||
normalizedUrl
|
||||
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
||||
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (
|
||||
normalizedUrl
|
||||
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
||||
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'captured',
|
||||
contributionCallbackMessage: buildCallbackMessage('captured'),
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'captured',
|
||||
contributionCallbackMessage: buildCallbackMessage('captured'),
|
||||
});
|
||||
|
||||
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 = {}) {
|
||||
@@ -597,16 +536,6 @@
|
||||
const callbackState = deriveCallbackState(mergedPayload, currentState);
|
||||
const updates = {
|
||||
contributionLastPollAt: Date.now(),
|
||||
contributionSource: normalizeContributionSource(
|
||||
mergedPayload.source
|
||||
|| mergedPayload.source_kind
|
||||
|| currentState.contributionSource
|
||||
),
|
||||
contributionTargetGroupName: normalizeString(
|
||||
mergedPayload.target_group_name
|
||||
|| mergedPayload.group_name
|
||||
|| currentState.contributionTargetGroupName
|
||||
),
|
||||
contributionStatus: normalizedStatus,
|
||||
contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload),
|
||||
contributionCallbackUrl: callbackState.callbackUrl,
|
||||
@@ -648,7 +577,6 @@
|
||||
async function startContributionFlow(options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const shouldOpenAuthTab = options.openAuthTab !== false;
|
||||
const routing = resolveContributionRouting(currentState);
|
||||
if (!currentState.contributionMode) {
|
||||
throw new Error('请先进入贡献模式。');
|
||||
}
|
||||
@@ -672,8 +600,7 @@
|
||||
nickname: buildNickname(currentState, options.nickname),
|
||||
qq: buildContributionQq(currentState, options.qq),
|
||||
email: normalizeString(currentState.email),
|
||||
source: routing.source,
|
||||
target_group_name: routing.targetGroupName,
|
||||
source: 'cpa',
|
||||
channel: 'codex-extension',
|
||||
},
|
||||
});
|
||||
@@ -686,12 +613,6 @@
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionSource: normalizeContributionSource(payload.source || routing.source),
|
||||
contributionTargetGroupName: normalizeString(
|
||||
payload.target_group_name
|
||||
|| payload.group_name
|
||||
|| routing.targetGroupName
|
||||
),
|
||||
contributionSessionId: sessionId,
|
||||
contributionAuthUrl: authUrl,
|
||||
contributionAuthState: authState,
|
||||
@@ -722,7 +643,7 @@
|
||||
}
|
||||
|
||||
function onTabUpdated(tabId, changeInfo, tab) {
|
||||
const candidateUrl = normalizeString(changeInfo?.url);
|
||||
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
|
||||
if (!candidateUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
+65
-150
@@ -40,9 +40,6 @@
|
||||
getPendingAutoRunTimerPlan,
|
||||
getSourceLabel,
|
||||
getState,
|
||||
getStepDefinitionForState,
|
||||
getStepIdsForState,
|
||||
getLastStepIdForState,
|
||||
getTabId,
|
||||
getStopRequested,
|
||||
handleAutoRunLoopUnhandledError,
|
||||
@@ -130,122 +127,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getStepKeyForState(step, state = {}) {
|
||||
if (typeof getStepDefinitionForState === 'function') {
|
||||
return String(getStepDefinitionForState(step, state)?.key || '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isStepProtectedFromAutoSkip(status) {
|
||||
return status === 'running'
|
||||
|| status === 'completed'
|
||||
|| status === 'manual_completed'
|
||||
|| status === 'skipped';
|
||||
}
|
||||
|
||||
function findStepByKeyAfter(currentStep, targetKey, state = {}) {
|
||||
const activeStepIds = typeof getStepIdsForState === 'function'
|
||||
? getStepIdsForState(state)
|
||||
: [];
|
||||
const candidates = activeStepIds.length ? activeStepIds : [Number(currentStep) + 1, 8];
|
||||
return candidates.find((stepId) => {
|
||||
const numericStep = Number(stepId);
|
||||
if (!Number.isFinite(numericStep) || numericStep <= Number(currentStep)) {
|
||||
return false;
|
||||
}
|
||||
const stepKey = getStepKeyForState(numericStep, state);
|
||||
if (stepKey) {
|
||||
return stepKey === targetKey;
|
||||
}
|
||||
return targetKey === 'fetch-login-code' && Number(currentStep) === 7 && numericStep === 8;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function handlePlatformVerifyStepData(payload) {
|
||||
if (payload.localhostUrl) {
|
||||
await closeLocalhostCallbackTabs(payload.localhostUrl);
|
||||
}
|
||||
const latestState = await getState();
|
||||
if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) {
|
||||
await patchHotmailAccount(latestState.currentHotmailAccountId, {
|
||||
used: true,
|
||||
lastUsedAt: Date.now(),
|
||||
});
|
||||
await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok');
|
||||
}
|
||||
if (String(latestState.mailProvider || '').trim().toLowerCase() === '2925' && latestState.currentMail2925AccountId) {
|
||||
await patchMail2925Account(latestState.currentMail2925AccountId, {
|
||||
lastUsedAt: Date.now(),
|
||||
lastError: '',
|
||||
});
|
||||
await addLog('当前 2925 账号已记录最近使用时间。', 'ok');
|
||||
}
|
||||
if (isLuckmailProvider(latestState)) {
|
||||
const currentPurchase = getCurrentLuckmailPurchase(latestState);
|
||||
if (currentPurchase?.id) {
|
||||
await setLuckmailPurchaseUsedState(currentPurchase.id, true);
|
||||
await addLog(`当前 LuckMail 邮箱 ${currentPurchase.email_address} 已在本地标记为已用。`, 'ok');
|
||||
}
|
||||
await clearLuckmailRuntimeState({ clearEmail: true });
|
||||
await addLog('当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。', 'ok');
|
||||
}
|
||||
const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl);
|
||||
if (localhostPrefix) {
|
||||
await closeTabsByUrlPrefix(localhostPrefix, {
|
||||
excludeUrls: [payload.localhostUrl],
|
||||
excludeLocalhostCallbacks: true,
|
||||
});
|
||||
}
|
||||
await finalizeIcloudAliasAfterSuccessfulFlow(latestState);
|
||||
}
|
||||
|
||||
async function handleStepData(step, payload) {
|
||||
const stateForStep = await getState();
|
||||
const stepKey = getStepKeyForState(step, stateForStep);
|
||||
|
||||
if (stepKey === 'oauth-login') {
|
||||
if (payload.skipLoginVerificationStep) {
|
||||
await setState({ loginVerificationRequestedAt: null });
|
||||
const latestState = await getState();
|
||||
const loginCodeStep = findStepByKeyAfter(step, 'fetch-login-code', latestState);
|
||||
if (loginCodeStep) {
|
||||
const currentStatus = latestState.stepStatuses?.[loginCodeStep];
|
||||
if (!isStepProtectedFromAutoSkip(currentStatus)) {
|
||||
await setStepStatus(loginCodeStep, 'skipped');
|
||||
await addLog(`步骤 ${step}:认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn');
|
||||
}
|
||||
}
|
||||
} else if (payload.loginVerificationRequestedAt) {
|
||||
await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepKey === 'fetch-login-code') {
|
||||
await setState({
|
||||
lastEmailTimestamp: payload.emailTimestamp || null,
|
||||
loginVerificationRequestedAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepKey === 'confirm-oauth') {
|
||||
if (payload.localhostUrl) {
|
||||
if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) {
|
||||
throw new Error(`步骤 ${step} 返回了无效的 localhost OAuth 回调地址。`);
|
||||
}
|
||||
await setState({ localhostUrl: payload.localhostUrl });
|
||||
broadcastDataUpdate({ localhostUrl: payload.localhostUrl });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepKey === 'platform-verify') {
|
||||
await handlePlatformVerifyStepData(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (step) {
|
||||
case 1: {
|
||||
const updates = {};
|
||||
@@ -258,6 +140,8 @@
|
||||
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
|
||||
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
|
||||
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
|
||||
if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null;
|
||||
if (payload.cpaManagementOrigin !== undefined) updates.cpaManagementOrigin = payload.cpaManagementOrigin || null;
|
||||
if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null;
|
||||
if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null;
|
||||
if (Object.keys(updates).length) {
|
||||
@@ -299,6 +183,11 @@
|
||||
await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
|
||||
}
|
||||
break;
|
||||
case 7:
|
||||
if (payload.loginVerificationRequestedAt) {
|
||||
await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
await setState({
|
||||
lastEmailTimestamp: payload.emailTimestamp || null,
|
||||
@@ -313,6 +202,59 @@
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 8:
|
||||
await setState({
|
||||
lastEmailTimestamp: payload.emailTimestamp || null,
|
||||
loginVerificationRequestedAt: null,
|
||||
});
|
||||
break;
|
||||
case 9:
|
||||
if (payload.localhostUrl) {
|
||||
if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) {
|
||||
throw new Error('步骤 9 返回了无效的 localhost OAuth 回调地址。');
|
||||
}
|
||||
await setState({ localhostUrl: payload.localhostUrl });
|
||||
broadcastDataUpdate({ localhostUrl: payload.localhostUrl });
|
||||
}
|
||||
break;
|
||||
case 10: {
|
||||
if (payload.localhostUrl) {
|
||||
await closeLocalhostCallbackTabs(payload.localhostUrl);
|
||||
}
|
||||
const latestState = await getState();
|
||||
if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) {
|
||||
await patchHotmailAccount(latestState.currentHotmailAccountId, {
|
||||
used: true,
|
||||
lastUsedAt: Date.now(),
|
||||
});
|
||||
await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok');
|
||||
}
|
||||
if (String(latestState.mailProvider || '').trim().toLowerCase() === '2925' && latestState.currentMail2925AccountId) {
|
||||
await patchMail2925Account(latestState.currentMail2925AccountId, {
|
||||
lastUsedAt: Date.now(),
|
||||
lastError: '',
|
||||
});
|
||||
await addLog('当前 2925 账号已记录最近使用时间。', 'ok');
|
||||
}
|
||||
if (isLuckmailProvider(latestState)) {
|
||||
const currentPurchase = getCurrentLuckmailPurchase(latestState);
|
||||
if (currentPurchase?.id) {
|
||||
await setLuckmailPurchaseUsedState(currentPurchase.id, true);
|
||||
await addLog(`当前 LuckMail 邮箱 ${currentPurchase.email_address} 已在本地标记为已用。`, 'ok');
|
||||
}
|
||||
await clearLuckmailRuntimeState({ clearEmail: true });
|
||||
await addLog('当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。', 'ok');
|
||||
}
|
||||
const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl);
|
||||
if (localhostPrefix) {
|
||||
await closeTabsByUrlPrefix(localhostPrefix, {
|
||||
excludeUrls: [payload.localhostUrl],
|
||||
excludeLocalhostCallbacks: true,
|
||||
});
|
||||
}
|
||||
await finalizeIcloudAliasAfterSuccessfulFlow(latestState);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -363,15 +305,11 @@
|
||||
return { ok: true, error: errorMessage };
|
||||
}
|
||||
|
||||
const completionStateCandidate = await getState();
|
||||
const lastStepId = typeof getLastStepIdForState === 'function'
|
||||
? getLastStepIdForState(completionStateCandidate)
|
||||
: 10;
|
||||
const completionState = message.step === lastStepId ? completionStateCandidate : null;
|
||||
const completionState = message.step === 10 ? await getState() : null;
|
||||
await setStepStatus(message.step, 'completed');
|
||||
await addLog(`步骤 ${message.step} 已完成`, 'ok');
|
||||
await handleStepData(message.step, message.payload);
|
||||
if (message.step === lastStepId && typeof appendAccountRunRecord === 'function') {
|
||||
if (message.step === 10 && typeof appendAccountRunRecord === 'function') {
|
||||
await appendAccountRunRecord('success', completionState);
|
||||
}
|
||||
notifyStepComplete(message.step, message.payload);
|
||||
@@ -520,8 +458,7 @@
|
||||
await setPersistentSettings({ emailPrefix: message.payload.emailPrefix });
|
||||
await setState({ emailPrefix: message.payload.emailPrefix });
|
||||
}
|
||||
const executionState = await getState();
|
||||
if (doesStepUseCompletionSignal(step, executionState)) {
|
||||
if (doesStepUseCompletionSignal(step)) {
|
||||
await executeStepViaCompletionSignal(step);
|
||||
} else {
|
||||
await executeStep(step);
|
||||
@@ -626,35 +563,13 @@
|
||||
}
|
||||
|
||||
case 'SAVE_SETTING': {
|
||||
const currentState = await getState();
|
||||
const updates = buildPersistentSettingsPayload(message.payload || {});
|
||||
const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {});
|
||||
const modeChanged = Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled')
|
||||
&& Boolean(currentState?.plusModeEnabled) !== Boolean(updates.plusModeEnabled);
|
||||
await setPersistentSettings(updates);
|
||||
const stateUpdates = {
|
||||
await setState({
|
||||
...updates,
|
||||
...sessionUpdates,
|
||||
};
|
||||
if (modeChanged && typeof getStepIdsForState === 'function') {
|
||||
const nextStateForSteps = { ...currentState, ...stateUpdates };
|
||||
stateUpdates.stepStatuses = Object.fromEntries(
|
||||
getStepIdsForState(nextStateForSteps).map((stepId) => [stepId, 'pending'])
|
||||
);
|
||||
stateUpdates.currentStep = 0;
|
||||
}
|
||||
await setState(stateUpdates);
|
||||
if (Boolean(currentState?.contributionMode) && typeof setContributionMode === 'function') {
|
||||
await setContributionMode(true);
|
||||
}
|
||||
if (modeChanged) {
|
||||
await addLog(
|
||||
Boolean(updates.plusModeEnabled)
|
||||
? 'Plus 模式已开启,已切换为 Plus Checkout + PayPal 步骤。'
|
||||
: 'Plus 模式已关闭,已恢复普通注册授权步骤。',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
});
|
||||
return { ok: true, state: await getState() };
|
||||
}
|
||||
|
||||
|
||||
+108
-37
@@ -43,6 +43,78 @@
|
||||
return message || `Codex2API 请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
function deriveCpaManagementOrigin(vpsUrl) {
|
||||
const normalizedUrl = String(vpsUrl || '').trim();
|
||||
if (!normalizedUrl) {
|
||||
throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。');
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(normalizedUrl);
|
||||
} catch {
|
||||
throw new Error('CPA 地址格式无效,请先在侧边栏检查。');
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function getCpaApiErrorMessage(payload, responseStatus = 500) {
|
||||
const candidates = [
|
||||
payload?.error,
|
||||
payload?.message,
|
||||
payload?.detail,
|
||||
payload?.reason,
|
||||
];
|
||||
const message = candidates
|
||||
.map((value) => String(value || '').trim())
|
||||
.find(Boolean);
|
||||
return message || `CPA 管理接口请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
async function fetchCpaManagementJson(origin, path, options = {}) {
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const managementKey = String(options.managementKey || '').trim();
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (managementKey) {
|
||||
headers.Authorization = `Bearer ${managementKey}`;
|
||||
headers['X-Management-Key'] = managementKey;
|
||||
}
|
||||
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
method: options.method || 'POST',
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getCpaApiErrorMessage(payload, response.status));
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('CPA 管理接口请求超时,请稍后重试。');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCodex2ApiJson(origin, path, options = {}) {
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
|
||||
const controller = new AbortController();
|
||||
@@ -97,49 +169,48 @@
|
||||
if (!state.vpsUrl) {
|
||||
throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。');
|
||||
}
|
||||
|
||||
await addLog(`${logLabel}:正在打开 CPA 面板...`);
|
||||
|
||||
const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'];
|
||||
await closeConflictingTabsForSource('vps-panel', state.vpsUrl);
|
||||
|
||||
const tab = await chrome.tabs.create({ url: state.vpsUrl, active: true });
|
||||
const tabId = tab.id;
|
||||
await rememberSourceLastUrl('vps-panel', state.vpsUrl);
|
||||
|
||||
await addLog(`${logLabel}:CPA 面板已打开,正在等待页面进入目标地址...`);
|
||||
const matchedTab = await waitForTabUrlFamily('vps-panel', tabId, state.vpsUrl, {
|
||||
timeoutMs: 15000,
|
||||
retryDelayMs: 400,
|
||||
});
|
||||
if (!matchedTab) {
|
||||
await addLog(`${logLabel}:CPA 页面尚未完全进入目标地址,继续尝试连接内容脚本...`, 'warn');
|
||||
const managementKey = String(state.vpsPassword || '').trim();
|
||||
if (!managementKey) {
|
||||
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
|
||||
}
|
||||
|
||||
await ensureContentScriptReadyOnTab('vps-panel', tabId, {
|
||||
inject: injectFiles,
|
||||
timeoutMs: 45000,
|
||||
retryDelayMs: 900,
|
||||
logMessage: `${logLabel}:CPA 面板仍在加载,正在重试连接内容脚本...`,
|
||||
const origin = deriveCpaManagementOrigin(state.vpsUrl);
|
||||
|
||||
await addLog(`${logLabel}:正在通过 CPA 管理接口获取 OAuth 授权链接...`);
|
||||
const result = await fetchCpaManagementJson(origin, '/v0/management/codex-auth-url', {
|
||||
method: 'GET',
|
||||
managementKey,
|
||||
});
|
||||
|
||||
const result = await sendToContentScriptResilient('vps-panel', {
|
||||
type: 'REQUEST_OAUTH_URL',
|
||||
source: 'background',
|
||||
payload: {
|
||||
vpsPassword: state.vpsPassword,
|
||||
logStep: 7,
|
||||
},
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
retryDelayMs: 700,
|
||||
logMessage: `${logLabel}:CPA 面板通信未就绪,正在等待页面恢复...`,
|
||||
});
|
||||
const oauthUrl = String(
|
||||
result?.url
|
||||
|| result?.auth_url
|
||||
|| result?.authUrl
|
||||
|| result?.data?.url
|
||||
|| result?.data?.auth_url
|
||||
|| result?.data?.authUrl
|
||||
|| ''
|
||||
).trim();
|
||||
const oauthState = String(
|
||||
result?.state
|
||||
|| result?.auth_state
|
||||
|| result?.authState
|
||||
|| result?.data?.state
|
||||
|| result?.data?.auth_state
|
||||
|| result?.data?.authState
|
||||
|| ''
|
||||
).trim()
|
||||
|| extractStateFromAuthUrl(oauthUrl);
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
if (!oauthUrl || !oauthUrl.startsWith('http')) {
|
||||
throw new Error('CPA 管理接口未返回有效的 auth_url。');
|
||||
}
|
||||
return result || {};
|
||||
|
||||
return {
|
||||
oauthUrl,
|
||||
cpaOAuthState: oauthState || null,
|
||||
cpaManagementOrigin: origin,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCodex2ApiOAuthUrl(state, options = {}) {
|
||||
|
||||
@@ -33,27 +33,16 @@
|
||||
setStep8TabUpdatedListener,
|
||||
} = deps;
|
||||
|
||||
function getVisibleStep(state, fallback = 9) {
|
||||
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
|
||||
return visibleStep > 0 ? visibleStep : fallback;
|
||||
}
|
||||
|
||||
function getAuthLoginStepForVisibleStep(visibleStep) {
|
||||
return visibleStep >= 12 ? 10 : 7;
|
||||
}
|
||||
|
||||
async function executeStep9(state) {
|
||||
const visibleStep = getVisibleStep(state, 9);
|
||||
if (!state.oauthUrl) {
|
||||
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
|
||||
throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}。`);
|
||||
throw new Error('缺少登录用 OAuth 链接,请先完成步骤 7。');
|
||||
}
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:正在监听 localhost 回调地址...`);
|
||||
await addLog('步骤 9:正在监听 localhost 回调地址...');
|
||||
|
||||
const callbackTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(240000, {
|
||||
step: visibleStep,
|
||||
step: 9,
|
||||
actionLabel: 'OAuth localhost 回调',
|
||||
})
|
||||
: 240000;
|
||||
@@ -82,8 +71,8 @@
|
||||
cleanupListener();
|
||||
clearTimeout(timeout);
|
||||
|
||||
addLog(`步骤 ${visibleStep}:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
|
||||
return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl });
|
||||
addLog(`步骤 9:已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
|
||||
return completeStepFromBackground(9, { localhostUrl: callbackUrl });
|
||||
}).then(() => {
|
||||
resolve();
|
||||
}).catch((err) => {
|
||||
@@ -92,7 +81,7 @@
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
rejectStep9(new Error(`${Math.round(callbackTimeoutMs / 1000)} 秒内未捕获到 localhost 回调跳转,步骤 ${visibleStep} 的点击可能被拦截了。`));
|
||||
rejectStep9(new Error('120 秒内未捕获到 localhost 回调跳转,步骤 9 的点击可能被拦截了。'));
|
||||
}, callbackTimeoutMs);
|
||||
|
||||
setStep8PendingReject((error) => {
|
||||
@@ -122,10 +111,10 @@
|
||||
|
||||
if (signupTabId && await isTabAlive('signup-page')) {
|
||||
await chrome.tabs.update(signupTabId, { active: true });
|
||||
await addLog(`步骤 ${visibleStep}:已切回认证页,正在准备调试器点击...`);
|
||||
await addLog('步骤 9:已切回认证页,正在准备调试器点击...');
|
||||
} else {
|
||||
signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl);
|
||||
await addLog(`步骤 ${visibleStep}:已重新打开认证页,正在准备调试器点击...`);
|
||||
await addLog('步骤 9:已重新打开认证页,正在准备调试器点击...');
|
||||
}
|
||||
|
||||
throwIfStep8SettledOrStopped(resolved);
|
||||
@@ -135,11 +124,11 @@
|
||||
await ensureStep8SignupPageReady(signupTabId, {
|
||||
timeoutMs: typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: visibleStep,
|
||||
step: 9,
|
||||
actionLabel: '等待 OAuth 同意页内容脚本就绪',
|
||||
})
|
||||
: 15000,
|
||||
logMessage: `步骤 ${visibleStep}:认证页内容脚本尚未就绪,正在等待页面恢复...`,
|
||||
logMessage: '步骤 9:认证页内容脚本尚未就绪,正在等待页面恢复...',
|
||||
});
|
||||
|
||||
for (let round = 1; round <= STEP8_MAX_ROUNDS && !resolved; round++) {
|
||||
@@ -148,7 +137,7 @@
|
||||
signupTabId,
|
||||
typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(STEP8_READY_WAIT_TIMEOUT_MS, {
|
||||
step: visibleStep,
|
||||
step: 9,
|
||||
actionLabel: '等待 OAuth 同意页出现',
|
||||
})
|
||||
: STEP8_READY_WAIT_TIMEOUT_MS
|
||||
@@ -160,12 +149,12 @@
|
||||
|
||||
const strategy = STEP8_STRATEGIES[Math.min(round - 1, STEP8_STRATEGIES.length - 1)];
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label})...`);
|
||||
await addLog(`步骤 9:第 ${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label})...`);
|
||||
|
||||
if (strategy.mode === 'debugger') {
|
||||
const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: visibleStep,
|
||||
step: 9,
|
||||
actionLabel: '定位 OAuth 同意页继续按钮',
|
||||
})
|
||||
: 15000;
|
||||
@@ -178,7 +167,7 @@
|
||||
} else {
|
||||
const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: visibleStep,
|
||||
step: 9,
|
||||
actionLabel: '点击 OAuth 同意页继续按钮',
|
||||
})
|
||||
: 15000;
|
||||
@@ -197,7 +186,7 @@
|
||||
pageState.url,
|
||||
typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: visibleStep,
|
||||
step: 9,
|
||||
actionLabel: '等待 OAuth 同意页点击生效',
|
||||
})
|
||||
: 15000
|
||||
@@ -207,20 +196,20 @@
|
||||
}
|
||||
|
||||
if (effect.progressed) {
|
||||
await addLog(`步骤 ${visibleStep}:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
|
||||
await addLog(`步骤 9:检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
|
||||
break;
|
||||
}
|
||||
|
||||
if (round >= STEP8_MAX_ROUNDS) {
|
||||
throw new Error(`步骤 ${visibleStep}:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
|
||||
throw new Error(`步骤 9:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
|
||||
}
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn');
|
||||
await addLog(`步骤 9:${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS})...`, 'warn');
|
||||
await reloadStep8ConsentPage(
|
||||
signupTabId,
|
||||
typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(30000, {
|
||||
step: visibleStep,
|
||||
step: 9,
|
||||
actionLabel: '刷新 OAuth 同意页',
|
||||
})
|
||||
: 30000
|
||||
|
||||
@@ -40,13 +40,7 @@
|
||||
return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message);
|
||||
}
|
||||
|
||||
function getVisibleStep(state, fallback = 7) {
|
||||
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
|
||||
return visibleStep > 0 ? visibleStep : fallback;
|
||||
}
|
||||
|
||||
async function executeStep7(state) {
|
||||
const visibleStep = getVisibleStep(state, 7);
|
||||
if (!state.email) {
|
||||
throw new Error('缺少邮箱地址,请先完成步骤 3。');
|
||||
}
|
||||
@@ -60,22 +54,22 @@
|
||||
try {
|
||||
const currentState = attempt === 1 ? state : await getState();
|
||||
const password = currentState.password || currentState.customPassword || '';
|
||||
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState, { visibleStep });
|
||||
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState);
|
||||
if (typeof startOAuthFlowTimeoutWindow === 'function') {
|
||||
await startOAuthFlowTimeoutWindow({ step: visibleStep, oauthUrl });
|
||||
await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl });
|
||||
}
|
||||
const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
|
||||
? await getOAuthFlowStepTimeoutMs(180000, {
|
||||
step: visibleStep,
|
||||
step: 7,
|
||||
actionLabel: 'OAuth 登录并进入验证码页',
|
||||
oauthUrl,
|
||||
})
|
||||
: 180000;
|
||||
|
||||
if (attempt === 1) {
|
||||
await addLog(`步骤 ${visibleStep}:正在打开最新 OAuth 链接并登录...`);
|
||||
await addLog('步骤 7:正在打开最新 OAuth 链接并登录...');
|
||||
} else {
|
||||
await addLog(`步骤 ${visibleStep}:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
|
||||
await addLog(`步骤 7:上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
|
||||
}
|
||||
|
||||
await reuseOrCreateTab('signup-page', oauthUrl);
|
||||
@@ -89,14 +83,13 @@
|
||||
payload: {
|
||||
email: currentState.email,
|
||||
password,
|
||||
visibleStep,
|
||||
},
|
||||
},
|
||||
{
|
||||
timeoutMs: loginTimeoutMs,
|
||||
responseTimeoutMs: loginTimeoutMs,
|
||||
retryDelayMs: 700,
|
||||
logMessage: `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪后继续登录...`,
|
||||
logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续登录...',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -105,16 +98,9 @@
|
||||
}
|
||||
|
||||
if (isStep6SuccessResult(result)) {
|
||||
const completionPayload = {
|
||||
await completeStepFromBackground(7, {
|
||||
loginVerificationRequestedAt: result.loginVerificationRequestedAt || null,
|
||||
};
|
||||
if (result.skipLoginVerificationStep) {
|
||||
completionPayload.skipLoginVerificationStep = true;
|
||||
}
|
||||
if (result.directOAuthConsentPage) {
|
||||
completionPayload.directOAuthConsentPage = true;
|
||||
}
|
||||
await completeStepFromBackground(visibleStep, completionPayload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,7 +118,7 @@
|
||||
}
|
||||
if (isManagementSecretConfigError(err)) {
|
||||
await addLog(
|
||||
`步骤 ${visibleStep}:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
|
||||
`步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
|
||||
'error'
|
||||
);
|
||||
throw err;
|
||||
@@ -142,11 +128,11 @@
|
||||
break;
|
||||
}
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
|
||||
await addLog(`步骤 7:第 ${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`步骤 ${visibleStep}:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
|
||||
throw new Error(`步骤 7:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
|
||||
}
|
||||
|
||||
return { executeStep7 };
|
||||
|
||||
@@ -26,31 +26,18 @@
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function getVisibleStep(state, fallback = 10) {
|
||||
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
|
||||
return visibleStep > 0 ? visibleStep : fallback;
|
||||
}
|
||||
|
||||
function getConfirmStepForVisibleStep(visibleStep) {
|
||||
return visibleStep >= 13 ? 12 : 9;
|
||||
}
|
||||
|
||||
function getAuthLoginStepForVisibleStep(visibleStep) {
|
||||
return visibleStep >= 13 ? 10 : 7;
|
||||
}
|
||||
|
||||
function parseLocalhostCallback(rawUrl, visibleStep = 10, confirmStep = 9) {
|
||||
function parseLocalhostCallback(rawUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 ${confirmStep}。`);
|
||||
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。');
|
||||
}
|
||||
|
||||
const code = normalizeString(parsed.searchParams.get('code'));
|
||||
const state = normalizeString(parsed.searchParams.get('state'));
|
||||
if (!code || !state) {
|
||||
throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 ${confirmStep}。`);
|
||||
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -72,6 +59,77 @@
|
||||
return details || `Codex2API 请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
function deriveCpaManagementOrigin(vpsUrl) {
|
||||
const normalizedUrl = normalizeString(vpsUrl);
|
||||
if (!normalizedUrl) {
|
||||
throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(normalizedUrl);
|
||||
} catch {
|
||||
throw new Error('CPA 地址格式无效,请先在侧边栏检查。');
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function getCpaApiErrorMessage(payload, responseStatus = 500) {
|
||||
const details = [
|
||||
payload?.error,
|
||||
payload?.message,
|
||||
payload?.detail,
|
||||
payload?.reason,
|
||||
]
|
||||
.map((value) => normalizeString(value))
|
||||
.find(Boolean);
|
||||
return details || `CPA 管理接口请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
async function fetchCpaManagementJson(origin, path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000));
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const managementKey = normalizeString(options.managementKey);
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (managementKey) {
|
||||
headers.Authorization = `Bearer ${managementKey}`;
|
||||
headers['X-Management-Key'] = managementKey;
|
||||
}
|
||||
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
method: options.method || 'POST',
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getCpaApiErrorMessage(payload, response.status));
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('CPA 管理接口请求超时,请稍后重试。');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCodex2ApiJson(origin, path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
|
||||
@@ -122,95 +180,86 @@
|
||||
}
|
||||
|
||||
async function executeCpaStep10(state) {
|
||||
const visibleStep = getVisibleStep(state, 10);
|
||||
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`);
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
}
|
||||
if (!state.localhostUrl) {
|
||||
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`);
|
||||
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
|
||||
}
|
||||
if (!state.vpsUrl) {
|
||||
throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
|
||||
}
|
||||
|
||||
if (shouldBypassStep9ForLocalCpa(state)) {
|
||||
await addLog(`步骤 ${visibleStep}:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。`, 'info');
|
||||
await completeStepFromBackground(visibleStep, {
|
||||
await addLog('步骤 10:检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。', 'info');
|
||||
await completeStepFromBackground(10, {
|
||||
localhostUrl: state.localhostUrl,
|
||||
verifiedStatus: 'local-auto',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:正在打开 CPA 面板...`);
|
||||
|
||||
const injectFiles = ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'];
|
||||
let tabId = await getTabId('vps-panel');
|
||||
const alive = tabId && await isTabAlive('vps-panel');
|
||||
|
||||
if (!alive) {
|
||||
tabId = await reuseOrCreateTab('vps-panel', state.vpsUrl, {
|
||||
inject: injectFiles,
|
||||
reloadIfSameUrl: true,
|
||||
});
|
||||
} else {
|
||||
await closeConflictingTabsForSource('vps-panel', state.vpsUrl, { excludeTabIds: [tabId] });
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
await rememberSourceLastUrl('vps-panel', state.vpsUrl);
|
||||
const callback = parseLocalhostCallback(state.localhostUrl);
|
||||
const expectedState = normalizeString(state.cpaOAuthState);
|
||||
if (expectedState && expectedState !== callback.state) {
|
||||
throw new Error('CPA 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
|
||||
}
|
||||
const managementKey = normalizeString(state.vpsPassword);
|
||||
if (!managementKey) {
|
||||
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
|
||||
}
|
||||
|
||||
await ensureContentScriptReadyOnTab('vps-panel', tabId, {
|
||||
inject: injectFiles,
|
||||
timeoutMs: 45000,
|
||||
retryDelayMs: 900,
|
||||
logMessage: `步骤 ${visibleStep}:CPA 面板仍在加载,正在重试连接...`,
|
||||
});
|
||||
await addLog('步骤 10:正在通过 CPA 管理接口提交回调地址...');
|
||||
try {
|
||||
const origin = normalizeString(state.cpaManagementOrigin) || deriveCpaManagementOrigin(state.vpsUrl);
|
||||
const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', {
|
||||
method: 'POST',
|
||||
managementKey,
|
||||
body: {
|
||||
provider: 'codex',
|
||||
redirect_url: callback.url,
|
||||
},
|
||||
});
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:正在填写回调地址...`);
|
||||
const result = await sendToContentScriptResilient('vps-panel', {
|
||||
type: 'EXECUTE_STEP',
|
||||
step: visibleStep,
|
||||
source: 'background',
|
||||
payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword, visibleStep },
|
||||
}, {
|
||||
timeoutMs: 125000,
|
||||
responseTimeoutMs: 125000,
|
||||
retryDelayMs: 700,
|
||||
logMessage: `步骤 ${visibleStep}:CPA 面板通信未就绪,正在等待页面恢复...`,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
const verifiedStatus = normalizeString(result?.message)
|
||||
|| normalizeString(result?.status_message)
|
||||
|| 'CPA 已通过接口提交回调';
|
||||
await addLog(`步骤 10:${verifiedStatus}`, 'ok');
|
||||
await completeStepFromBackground(10, {
|
||||
localhostUrl: callback.url,
|
||||
verifiedStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = normalizeString(error?.message) || 'unknown error';
|
||||
await addLog(`步骤 10:CPA 接口提交失败:${reason}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCodex2ApiStep10(state) {
|
||||
const visibleStep = getVisibleStep(state, 10);
|
||||
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`);
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
}
|
||||
if (!state.localhostUrl) {
|
||||
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`);
|
||||
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
|
||||
}
|
||||
if (!state.codex2apiSessionId) {
|
||||
throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`);
|
||||
throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。');
|
||||
}
|
||||
if (!normalizeString(state.codex2apiAdminKey)) {
|
||||
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
|
||||
}
|
||||
|
||||
const callback = parseLocalhostCallback(state.localhostUrl, visibleStep, confirmStep);
|
||||
const callback = parseLocalhostCallback(state.localhostUrl);
|
||||
const expectedState = normalizeString(state.codex2apiOAuthState);
|
||||
if (expectedState && expectedState !== callback.state) {
|
||||
throw new Error(`Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 ${getAuthLoginStepForVisibleStep(visibleStep)}。`);
|
||||
throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
|
||||
}
|
||||
|
||||
const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
|
||||
const origin = new URL(codex2apiUrl).origin;
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:正在向 Codex2API 提交回调并创建账号...`);
|
||||
await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...');
|
||||
const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', {
|
||||
adminKey: state.codex2apiAdminKey,
|
||||
method: 'POST',
|
||||
@@ -222,21 +271,19 @@
|
||||
});
|
||||
|
||||
const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
|
||||
await addLog(`步骤 ${visibleStep}:${verifiedStatus}`, 'ok');
|
||||
await completeStepFromBackground(visibleStep, {
|
||||
await addLog(`步骤 10:${verifiedStatus}`, 'ok');
|
||||
await completeStepFromBackground(10, {
|
||||
localhostUrl: callback.url,
|
||||
verifiedStatus,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSub2ApiStep10(state) {
|
||||
const visibleStep = getVisibleStep(state, 10);
|
||||
const confirmStep = getConfirmStepForVisibleStep(visibleStep);
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error(`步骤 ${confirmStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmStep}。`);
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
}
|
||||
if (!state.localhostUrl) {
|
||||
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmStep}。`);
|
||||
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
|
||||
}
|
||||
if (!state.sub2apiSessionId) {
|
||||
throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。');
|
||||
@@ -251,7 +298,7 @@
|
||||
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
|
||||
const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:正在打开 SUB2API 后台...`);
|
||||
await addLog('步骤 10:正在打开 SUB2API 后台...');
|
||||
|
||||
let tabId = await getTabId('sub2api-panel');
|
||||
const alive = tabId && await isTabAlive('sub2api-panel');
|
||||
@@ -273,13 +320,12 @@
|
||||
injectSource: 'sub2api-panel',
|
||||
});
|
||||
|
||||
await addLog(`步骤 ${visibleStep}:正在向 SUB2API 提交回调并创建账号...`);
|
||||
await addLog('步骤 10:正在向 SUB2API 提交回调并创建账号...');
|
||||
const result = await sendToContentScript('sub2api-panel', {
|
||||
type: 'EXECUTE_STEP',
|
||||
step: visibleStep,
|
||||
step: 10,
|
||||
source: 'background',
|
||||
payload: {
|
||||
visibleStep,
|
||||
localhostUrl: state.localhostUrl,
|
||||
sub2apiUrl,
|
||||
sub2apiEmail: state.sub2apiEmail,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
test('background step-1 state plumbing persists and resets cpa oauth runtime keys', () => {
|
||||
const source = fs.readFileSync('background.js', 'utf8');
|
||||
|
||||
assert.match(source, /cpaOAuthState:\s*null/);
|
||||
assert.match(source, /cpaManagementOrigin:\s*null/);
|
||||
assert.match(source, /payload\.cpaOAuthState[^\n]*updates\.cpaOAuthState/);
|
||||
assert.match(source, /payload\.cpaManagementOrigin[^\n]*updates\.cpaManagementOrigin/);
|
||||
assert.match(source, /if \(step <= 1\) \{[\s\S]*cpaOAuthState:\s*null,[\s\S]*cpaManagementOrigin:\s*null,/);
|
||||
});
|
||||
|
||||
test('message router step-1 handler stores cpa oauth runtime keys', async () => {
|
||||
const source = fs.readFileSync('background/message-router.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
|
||||
|
||||
const updates = [];
|
||||
const router = api.createMessageRouter({
|
||||
broadcastDataUpdate: () => {},
|
||||
setState: async (payload) => {
|
||||
updates.push(payload);
|
||||
},
|
||||
});
|
||||
|
||||
await router.handleStepData(1, {
|
||||
cpaOAuthState: 'oauth-state-1',
|
||||
cpaManagementOrigin: 'http://localhost:8317',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(updates, [
|
||||
{
|
||||
cpaOAuthState: 'oauth-state-1',
|
||||
cpaManagementOrigin: 'http://localhost:8317',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -71,3 +71,48 @@ test('panel bridge can request codex2api oauth url via protocol', async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('panel bridge can request cpa oauth url via management api', async () => {
|
||||
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
assert.equal(url, 'http://localhost:8317/v0/management/codex-auth-url');
|
||||
assert.equal(options.method, 'GET');
|
||||
assert.equal(options.headers.Authorization, 'Bearer cpa-key');
|
||||
assert.equal(options.headers['X-Management-Key'], 'cpa-key');
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 'ok',
|
||||
url: 'https://auth.openai.com/authorize?state=cpa-oauth-state',
|
||||
state: 'cpa-oauth-state',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
|
||||
const bridge = api.createPanelBridge({
|
||||
addLog: async () => {},
|
||||
getPanelMode: () => 'cpa',
|
||||
normalizeCodex2ApiUrl: (value) => value,
|
||||
normalizeSub2ApiUrl: (value) => value,
|
||||
DEFAULT_SUB2API_GROUP_NAME: 'codex',
|
||||
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
|
||||
});
|
||||
|
||||
const result = await bridge.requestOAuthUrlFromPanel({
|
||||
panelMode: 'cpa',
|
||||
vpsUrl: 'http://localhost:8317/admin/oauth',
|
||||
vpsPassword: 'cpa-key',
|
||||
}, { logLabel: '步骤 7' });
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
oauthUrl: 'https://auth.openai.com/authorize?state=cpa-oauth-state',
|
||||
cpaOAuthState: 'cpa-oauth-state',
|
||||
cpaManagementOrigin: 'http://localhost:8317',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
|
||||
function createDeps(overrides = {}) {
|
||||
const logs = [];
|
||||
const completed = [];
|
||||
const uiCalls = [];
|
||||
|
||||
const deps = {
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
closeConflictingTabsForSource: async () => {},
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completed.push({ step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
getPanelMode: () => 'cpa',
|
||||
getTabId: async () => 0,
|
||||
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
|
||||
isTabAlive: async () => false,
|
||||
normalizeCodex2ApiUrl: (value) => value,
|
||||
normalizeSub2ApiUrl: (value) => value,
|
||||
rememberSourceLastUrl: async () => {},
|
||||
reuseOrCreateTab: async () => 91,
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async (source, message, options) => {
|
||||
uiCalls.push({ source, message, options });
|
||||
return {};
|
||||
},
|
||||
shouldBypassStep9ForLocalCpa: () => false,
|
||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { deps, logs, completed, uiCalls };
|
||||
}
|
||||
|
||||
test('platform verify module submits CPA callback via management API first', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
let uiCalled = false;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
assert.equal(url, 'http://localhost:8317/v0/management/oauth-callback');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.equal(options.headers.Authorization, 'Bearer cpa-key');
|
||||
assert.equal(options.headers['X-Management-Key'], 'cpa-key');
|
||||
assert.deepStrictEqual(JSON.parse(options.body), {
|
||||
provider: 'codex',
|
||||
redirect_url: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: 'CPA API 回调提交成功',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
const { deps, logs, completed } = createDeps({
|
||||
sendToContentScriptResilient: async () => {
|
||||
uiCalled = true;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const executor = api.createStep10Executor(deps);
|
||||
|
||||
await executor.executeStep10({
|
||||
panelMode: 'cpa',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
vpsUrl: 'http://localhost:8317/admin/oauth',
|
||||
vpsPassword: 'cpa-key',
|
||||
});
|
||||
|
||||
assert.equal(uiCalled, false);
|
||||
assert.deepStrictEqual(completed, [
|
||||
{
|
||||
step: 10,
|
||||
payload: {
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
verifiedStatus: 'CPA API 回调提交成功',
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(logs, [
|
||||
{ message: '步骤 10:正在通过 CPA 管理接口提交回调地址...', level: 'info' },
|
||||
{ message: '步骤 10:CPA API 回调提交成功', level: 'ok' },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('platform verify module prefers cpaManagementOrigin when provided', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
assert.equal(url, 'http://localhost:9999/v0/management/oauth-callback');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.equal(options.headers.Authorization, 'Bearer cpa-key');
|
||||
assert.equal(options.headers['X-Management-Key'], 'cpa-key');
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: 'CPA API 回调提交成功',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
const { deps, completed } = createDeps();
|
||||
const executor = api.createStep10Executor(deps);
|
||||
|
||||
await executor.executeStep10({
|
||||
panelMode: 'cpa',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
cpaManagementOrigin: 'http://localhost:9999',
|
||||
vpsUrl: 'http://localhost:8317/admin/oauth',
|
||||
vpsPassword: 'cpa-key',
|
||||
});
|
||||
|
||||
assert.equal(completed.length, 1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('platform verify module fails fast when CPA API submit fails', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ message: 'failed to persist oauth callback' }),
|
||||
});
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
const { deps, logs, completed, uiCalls } = createDeps();
|
||||
const executor = api.createStep10Executor(deps);
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep10({
|
||||
panelMode: 'cpa',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
vpsUrl: 'http://localhost:8317/admin/oauth',
|
||||
vpsPassword: 'cpa-key',
|
||||
}),
|
||||
/failed to persist oauth callback/
|
||||
);
|
||||
|
||||
assert.equal(uiCalls.length, 0);
|
||||
assert.equal(completed.length, 0);
|
||||
assert.equal(logs[0].message, '步骤 10:正在通过 CPA 管理接口提交回调地址...');
|
||||
assert.match(logs[1].message, /步骤 10:CPA 接口提交失败:failed to persist oauth callback/);
|
||||
assert.equal(logs[1].level, 'error');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('platform verify module requires management key for CPA API-only flow', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalled = true;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
const { deps, logs, completed, uiCalls } = createDeps();
|
||||
const executor = api.createStep10Executor(deps);
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep10({
|
||||
panelMode: 'cpa',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
vpsUrl: 'http://localhost:8317/admin/oauth',
|
||||
vpsPassword: ' ',
|
||||
}),
|
||||
/尚未配置 CPA 管理密钥/
|
||||
);
|
||||
|
||||
assert.equal(fetchCalled, false);
|
||||
assert.equal(uiCalls.length, 0);
|
||||
assert.equal(completed.length, 0);
|
||||
assert.equal(logs.length, 0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('platform verify module rejects callback when cpa oauth state mismatches', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalled = true;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ message: 'should not happen' }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
const { deps } = createDeps();
|
||||
const executor = api.createStep10Executor(deps);
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep10({
|
||||
panelMode: 'cpa',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=callback-state',
|
||||
cpaOAuthState: 'expected-state',
|
||||
vpsUrl: 'http://localhost:8317/admin/oauth',
|
||||
vpsPassword: 'cpa-key',
|
||||
}),
|
||||
/CPA 回调 state 与当前授权会话不匹配/
|
||||
);
|
||||
|
||||
assert.equal(fetchCalled, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
+3
-68
@@ -23,7 +23,6 @@
|
||||
- 轮询登录验证码
|
||||
- 自动确认 OAuth 同意页
|
||||
- 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API
|
||||
- 可选开启 Plus 模式:先完成 Plus Checkout、账单地址、PayPal 登录授权与订阅回跳确认,再复用 OAuth 后半段链路
|
||||
|
||||
## 2. 核心运行参与者
|
||||
|
||||
@@ -43,9 +42,8 @@
|
||||
- 在顶部“贡献/使用”按钮下方展示一个非强制的内容更新轻提示;提示来源于 `apikey.qzz.io` 的公开公告 / 教程摘要,用户关闭后仅对当前 `promptVersion` 静默,下次内容版本变化后会重新出现
|
||||
- 在 sidepanel 初始化和点击“自动”按钮前刷新一次贡献站公开内容摘要;如果刷新失败,不阻塞主自动流程
|
||||
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
|
||||
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 三个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro` 或 `v` 版本误显示为比 `Ultra` 更新
|
||||
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新
|
||||
- 展示一个单独的“接码”开关;开启后才展示 HeroSMS 的接码国家与 API Key 设置,用于 OAuth 登录链路命中手机号验证页时直接续跑手机验证
|
||||
- 展示 `Plus 模式` 开关与 PayPal 账号/密码配置;开启后步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
|
||||
- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
|
||||
|
||||
### 2.2 Background Service Worker
|
||||
@@ -118,7 +116,6 @@
|
||||
- 步骤顺序靠 `order`
|
||||
- 步骤文件名靠语义
|
||||
- 新增步骤时不需要重命名后续文件
|
||||
- 普通模式使用 10 步定义;Plus 模式使用 13 步定义,其中 Plus 可见步骤 10/11/12/13 复用原 OAuth 登录、登录验证码、OAuth 同意页与平台回调验证执行器,但按 Plus 可见步骤编号记录状态
|
||||
|
||||
## 4. 状态与存储链路
|
||||
|
||||
@@ -141,7 +138,6 @@
|
||||
- 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail`
|
||||
- 当前手机号验证激活记录 `currentPhoneActivation`
|
||||
- 可复用的手机号验证激活记录 `reusablePhoneActivation`
|
||||
- Plus checkout / PayPal 运行态:`plusCheckoutTabId`、`plusCheckoutUrl`、`plusCheckoutCountry`、`plusCheckoutCurrency`、`plusBillingCountryText`、`plusBillingAddress`、`plusPaypalApprovedAt`、`plusReturnUrl`
|
||||
- localhost 回调地址
|
||||
- 自动运行轮次信息
|
||||
- 当前自动运行 session 标识 `autoRunSessionId`
|
||||
@@ -160,8 +156,6 @@
|
||||
|
||||
- CPA / SUB2API 配置
|
||||
- Codex2API 配置
|
||||
- Plus 模式开关 `plusModeEnabled`
|
||||
- PayPal 登录配置 `paypalEmail / paypalPassword`
|
||||
- 邮箱 provider 配置
|
||||
- Hotmail 账号池
|
||||
- 2925 账号池
|
||||
@@ -169,8 +163,7 @@
|
||||
- 2925 当前选中的号池账号 ID `currentMail2925AccountId`
|
||||
- Cloudflare / Temp Email 设置
|
||||
- 接码开关,以及 HeroSMS 的 API Key 与默认国家设置
|
||||
- iCloud 相关偏好:Host、获取策略、成功后自动删除、目标邮箱类型与转发收码邮箱 provider
|
||||
- iCloud Hide My Email 别名缓存:`icloudAliasCache` / `icloudAliasCacheAt`,用于在 iCloud 会话或网络上下文短暂波动时回退展示最近可用列表
|
||||
- iCloud 相关偏好
|
||||
- LuckMail API 配置
|
||||
- 自动运行默认配置
|
||||
- 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止)
|
||||
@@ -453,7 +446,6 @@
|
||||
补充:
|
||||
|
||||
- HeroSMS 号码当前最多复用 3 次成功注册;超过上限后会清空可复用激活记录,下次重新申请新号码。
|
||||
- HeroSMS 新号码申请会先查询当前国家与 OpenAI 服务的最低价并携带固定价格参数;如果 `getNumber` 返回 `NO_NUMBERS`,会回退到 `getNumberV2`,后续用 `getStatusV2` 轮询验证码。
|
||||
- 如果同一个号码在重发短信后 60 秒仍收不到验证码,后台会抛出“回到步骤 7 重新拿新号码”的恢复错误,而不是把当前号码无限重试下去。
|
||||
|
||||
### Step 10
|
||||
@@ -487,35 +479,6 @@ Codex2API 补充:
|
||||
- 步骤 10 会先尝试提交已捕获的 callback URL,随后轮询公开贡献状态,直到进入最终态
|
||||
- `auto_approved` 与 `manual_review_required` 视为主流程完成;`auto_rejected / expired / error` 视为当前轮失败
|
||||
|
||||
## 6.1 Plus 模式链路
|
||||
|
||||
Plus 模式通过 `plusModeEnabled` 开启,目标是在普通注册资料完成后,不执行原 Step 6 Cookie 清理,也不执行原 Step 8 登录验证码步骤,而是先完成 Plus Checkout 与 PayPal 授权,再复用现有 OAuth 后半段。
|
||||
|
||||
Plus 模式可见步骤:
|
||||
|
||||
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。
|
||||
2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`。
|
||||
3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frame,Google 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。
|
||||
4. 第 8 步 `PayPal 登录与授权`:在 PayPal 页面填写 `paypalEmail / paypalPassword`,登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。
|
||||
5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。
|
||||
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
|
||||
7. 第 11 步:复用原 Step 8 登录验证码执行器,但状态和日志按 Plus 可见第 11 步记录。
|
||||
8. 第 12 步:复用原 Step 9 OAuth 同意页点击和 localhost callback 捕获执行器,但状态和日志按 Plus 可见第 12 步记录。
|
||||
9. 第 13 步:复用原 Step 10 CPA / SUB2API / Codex2API 平台回调验证执行器,但状态和日志按 Plus 可见第 13 步记录。
|
||||
|
||||
隐藏与跳过规则:
|
||||
|
||||
- 原 Step 6 `清理登录 Cookies`:Plus 模式下隐藏且不执行,因为 Plus Checkout 创建依赖当前 ChatGPT 登录态。
|
||||
- 原 Step 8 `获取登录验证码`:Plus 模式下移动到可见第 11 步,继续负责登录验证码页的收码与提交。
|
||||
- 原 Step 9 不能跳过;它负责点击 OAuth 同意页并捕获 `localhostUrl`。
|
||||
- 原 Step 10 不能跳过;它依赖 `localhostUrl` 完成平台侧账号创建或回调验证。
|
||||
|
||||
等待模型:
|
||||
|
||||
- Plus Checkout 和 PayPal 专用步骤使用“无限等待但可停止”的后台等待 helper。
|
||||
- 每次页面加载完成后固定等待 1 秒,再继续下一次输入、点击或状态判断。
|
||||
- 第一版不实时抓取外部地址网站;地址自动化只依赖本地 seed query 和 checkout 页面内置 Google 地址推荐。
|
||||
|
||||
## 7. 邮箱与 provider 链路
|
||||
|
||||
### 7.1 2026-04-17 补充:Gmail / 2925 统一别名邮箱链路
|
||||
@@ -642,7 +605,6 @@ Plus 模式可见步骤:
|
||||
- `163`、`163 VIP`、`126` 都走同一条“网易网页邮箱”验证码链路。
|
||||
- sidepanel 只负责切换 provider 与展示登录入口;后台根据 provider 选择对应网页邮箱首页。
|
||||
- 内容脚本来源统一归类到 `mail-163`,这样 Step 4 / Step 8 继续复用同一套验证码读取与邮件清理逻辑。
|
||||
- `mail-provider-utils.js` 同时承接 iCloud 转发收码可选 provider 的归一化与入口配置,避免 background / sidepanel 重新复制 QQ、网易和 Gmail 的收码地址、label 与注入脚本。
|
||||
- `manifest.json` 需要同时覆盖:
|
||||
- `https://mail.163.com/*`
|
||||
- `https://webmail.vip.163.com/*`
|
||||
@@ -737,33 +699,8 @@ Plus 模式可见步骤:
|
||||
组成:
|
||||
|
||||
- [icloud-utils.js](c:/Users/projectf/Downloads/codex注册扩展/icloud-utils.js)
|
||||
- [mail-provider-utils.js](c:/Users/projectf/Downloads/codex注册扩展/mail-provider-utils.js)
|
||||
- [content/icloud-mail.js](c:/Users/projectf/Downloads/codex注册扩展/content/icloud-mail.js)
|
||||
|
||||
配置:
|
||||
|
||||
- `icloudHostPreference` 只决定 iCloud 登录、别名管理和 iCloud Mail 收件箱 Host。
|
||||
- `icloudFetchMode` 决定生成注册邮箱时复用已有 Hide My Email 别名,还是始终创建新别名。
|
||||
- `icloudTargetMailboxType = icloud-inbox` 时,Step 4 / Step 8 直接打开 iCloud Mail 收件箱轮询验证码。
|
||||
- `icloudTargetMailboxType = forward-mailbox` 时,注册邮箱仍由 iCloud Hide My Email 生成,但 Step 4 / Step 8 改为打开 `icloudForwardMailProvider` 指定的转发目标邮箱收码;当前支持 `qq / 163 / 163-vip / 126 / gmail`。
|
||||
|
||||
Hide My Email 获取与管理链路:
|
||||
|
||||
1. 后台优先通过 `setup.icloud.com*/setup/ws/1/validate` 解析 `premiummailsettings.url`,并记录最近可用服务节点。
|
||||
2. 如果后台请求受 401 / 403 / 409 / 421 / 429 / 5xx、CORS、超时或地址空间限制影响,会优先尝试在已打开或自动创建的 `www.icloud.com*/mail/` 页面主上下文中重放请求。
|
||||
3. `maildomainws` 请求会补充 `clientBuildNumber / clientMasteringNumber / clientId / dsid` 查询参数,写请求使用 `text/plain;charset=UTF-8`,以兼容 iCloud 网页端接口。
|
||||
4. 别名列表加载成功后会缓存到 `icloudAliasCache`;遇到短暂上下文波动时,列表展示会按“最近缓存 -> 历史缓存 -> 本地已用/保留记录”回退。
|
||||
5. 生成新别名后若 `reserve` 返回鉴权或网络异常,会先回查列表确认候选别名是否已经创建;必要时刷新服务节点重试一次,最后才进入可复用别名回退或暂停等待用户处理。
|
||||
6. 自动运行获取 iCloud 别名时,检测到会话或网络上下文异常后会停止重复消耗获取重试次数,转入等待邮箱状态,避免连续请求放大 iCloud 风控或上下文抖动。
|
||||
|
||||
维护约定:
|
||||
|
||||
- iCloud 转发目标邮箱 provider 的 label、URL、source、inject 配置统一由 `mail-provider-utils.js` 输出。
|
||||
- `background.js` 只根据 `icloudTargetMailboxType` 选择 iCloud Mail 收件箱或共享转发邮箱配置,不再在主文件内硬编码各 provider 地址。
|
||||
- `sidepanel/sidepanel.js` 只负责展示、保存和回显目标邮箱类型 / 转发邮箱 provider,不重复定义 provider 业务清单。
|
||||
- iCloud 登录态提示必须区分“真实未登录”和“已登录但请求上下文波动”;不能把所有 401 / 403 / 421 都直接提示为未登录。
|
||||
- iCloud 别名缓存只用于短暂失败回退,不应改变最终的已用 / 保留状态来源;状态仍以 `manualAliasUsage`、`preservedAliases` 与最新线上列表合并后的结果为准。
|
||||
|
||||
## 8. 自动运行完整链路
|
||||
|
||||
文件:
|
||||
@@ -779,11 +716,9 @@ Hide My Email 获取与管理链路:
|
||||
- 如果当前 `Mail = 自定义邮箱` 且配置了 `customMailProviderPool`,会先按当前目标轮次把号池中的对应邮箱写回运行态
|
||||
- 如果当前生成方式是 `custom-pool`,会先按当前目标轮次把邮箱池中的对应邮箱写回运行态
|
||||
5. 执行 `runAutoSequenceFromStep`
|
||||
- 自动运行会按当前 `plusModeEnabled` 选择普通 10 步或 Plus 13 步可见步骤;Plus 模式下第 6~9 步走 checkout / PayPal,第 10~13 步复用 OAuth 后半段
|
||||
- 步骤 7 内部仍保留登录态恢复的有限重试,但 `add-phone / 手机号页` 属于立即跳出的不可重试错误
|
||||
- 步骤 8 若在验证码提交后进入 `add-phone / 手机号页`,会直接抛出 fatal 错误,不再先标记步骤成功
|
||||
- 普通模式一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开
|
||||
- Plus 模式在 checkout / PayPal 结束后,如果 OAuth 后半段遇到普通报错且认证流程未进入 `add-phone`,则自动回到 Plus 可见步骤 10 重新开始授权链路
|
||||
- 一旦进入步骤 7~10,遇到普通报错且认证流程未进入 `add-phone`,则自动回到步骤 7 无限重开
|
||||
- 如果命中 `add-phone / 手机号页` 这类 fatal 错误,则不会再做当前轮的内部重试;当开启自动重试/跳过失败时,会直接结束当前轮并继续下一轮,而不是把整条自动流程暂停
|
||||
- 如果是手动停止,则立即退出自动流程,不会再触发“回到步骤 7 重开”
|
||||
6. 如果失败,根据设置决定:
|
||||
|
||||
Reference in New Issue
Block a user