cpa-api.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. // background/cpa-api.js — CPA management API helpers for Codex auth JSON import
  2. (function attachBackgroundCpaApi(root, factory) {
  3. root.MultiPageBackgroundCpaApi = factory();
  4. })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundCpaApiModule() {
  5. function createCpaApi(deps = {}) {
  6. const {
  7. addLog = async () => {},
  8. fetchImpl = (...args) => fetch(...args),
  9. } = deps;
  10. function normalizeString(value = '') {
  11. return String(value || '').trim();
  12. }
  13. function isPlainObject(value) {
  14. return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
  15. }
  16. function firstNonEmpty(...values) {
  17. for (const value of values) {
  18. const normalized = normalizeString(value);
  19. if (normalized) return normalized;
  20. }
  21. return '';
  22. }
  23. function normalizeEmailValue(value = '') {
  24. const email = normalizeString(value);
  25. return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : '';
  26. }
  27. function deriveCpaManagementOrigin(vpsUrl) {
  28. const normalizedUrl = normalizeString(vpsUrl);
  29. if (!normalizedUrl) {
  30. throw new Error('尚未配置 CPA 地址,请先在侧边栏填写。');
  31. }
  32. try {
  33. return new URL(normalizedUrl).origin;
  34. } catch {
  35. throw new Error('CPA 地址格式无效,请先在侧边栏检查。');
  36. }
  37. }
  38. function getCpaApiErrorMessage(payload, responseStatus = 500) {
  39. const candidates = [
  40. payload?.error,
  41. payload?.message,
  42. payload?.detail,
  43. payload?.reason,
  44. ];
  45. const message = candidates.map(normalizeString).find(Boolean);
  46. return message || `CPA 管理接口请求失败(HTTP ${responseStatus})。`;
  47. }
  48. async function fetchCpaManagementJson(origin, path, options = {}) {
  49. const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 20000));
  50. const controller = new AbortController();
  51. const timer = setTimeout(() => controller.abort(), timeoutMs);
  52. try {
  53. const managementKey = normalizeString(options.managementKey);
  54. const headers = {
  55. Accept: 'application/json',
  56. 'Content-Type': 'application/json',
  57. };
  58. if (managementKey) {
  59. headers.Authorization = `Bearer ${managementKey}`;
  60. headers['X-Management-Key'] = managementKey;
  61. }
  62. const response = await fetchImpl(`${origin}${path}`, {
  63. method: options.method || 'POST',
  64. headers,
  65. body: options.body === undefined ? undefined : JSON.stringify(options.body),
  66. signal: controller.signal,
  67. });
  68. let payload = {};
  69. try {
  70. payload = await response.json();
  71. } catch {
  72. payload = {};
  73. }
  74. if (!response.ok) {
  75. throw new Error(getCpaApiErrorMessage(payload, response.status));
  76. }
  77. return payload;
  78. } catch (error) {
  79. if (error?.name === 'AbortError') {
  80. throw new Error('CPA 管理接口请求超时,请稍后重试。');
  81. }
  82. throw error;
  83. } finally {
  84. clearTimeout(timer);
  85. }
  86. }
  87. function decodeBase64UrlSegment(segment = '') {
  88. const normalized = normalizeString(segment)
  89. .replace(/-/g, '+')
  90. .replace(/_/g, '/');
  91. if (!normalized) return '';
  92. const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
  93. try {
  94. if (typeof Buffer !== 'undefined') {
  95. return Buffer.from(padded, 'base64').toString('utf8');
  96. }
  97. if (typeof atob === 'function') {
  98. const binary = atob(padded);
  99. const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
  100. if (typeof TextDecoder !== 'undefined') {
  101. return new TextDecoder().decode(bytes);
  102. }
  103. return binary;
  104. }
  105. } catch {
  106. return '';
  107. }
  108. return '';
  109. }
  110. function encodeBase64UrlJson(value) {
  111. const json = JSON.stringify(value);
  112. if (typeof Buffer !== 'undefined') {
  113. return Buffer.from(json, 'utf8')
  114. .toString('base64')
  115. .replace(/\+/g, '-')
  116. .replace(/\//g, '_')
  117. .replace(/=+$/g, '');
  118. }
  119. const bytes = new TextEncoder().encode(json);
  120. let binary = '';
  121. bytes.forEach((byte) => {
  122. binary += String.fromCharCode(byte);
  123. });
  124. return btoa(binary)
  125. .replace(/\+/g, '-')
  126. .replace(/\//g, '_')
  127. .replace(/=+$/g, '');
  128. }
  129. function parseJwtPayload(token = '') {
  130. const normalized = normalizeString(token);
  131. if (!normalized) return null;
  132. const parts = normalized.split('.');
  133. if (parts.length < 2) return null;
  134. try {
  135. return JSON.parse(decodeBase64UrlSegment(parts[1]));
  136. } catch {
  137. return null;
  138. }
  139. }
  140. function getOpenAiAuthSection(payload) {
  141. if (!isPlainObject(payload)) return {};
  142. const auth = payload['https://api.openai.com/auth'];
  143. return isPlainObject(auth) ? auth : {};
  144. }
  145. function getOpenAiProfileSection(payload) {
  146. if (!isPlainObject(payload)) return {};
  147. const profile = payload['https://api.openai.com/profile'];
  148. return isPlainObject(profile) ? profile : {};
  149. }
  150. function normalizeTimestamp(value) {
  151. if (value instanceof Date && !Number.isNaN(value.getTime())) {
  152. return value.toISOString();
  153. }
  154. if (typeof value === 'number' && Number.isFinite(value)) {
  155. const milliseconds = value > 1e11 ? value : value * 1000;
  156. const date = new Date(milliseconds);
  157. return Number.isNaN(date.getTime()) ? '' : date.toISOString();
  158. }
  159. if (typeof value !== 'string' || !value.trim()) return '';
  160. const date = new Date(value);
  161. return Number.isNaN(date.getTime()) ? '' : date.toISOString();
  162. }
  163. function timestampFromUnixSeconds(value) {
  164. const numeric = Number(value);
  165. if (!Number.isFinite(numeric)) return '';
  166. const date = new Date(numeric * 1000);
  167. return Number.isNaN(date.getTime()) ? '' : date.toISOString();
  168. }
  169. function epochSecondsFromValue(value) {
  170. if (value === undefined || value === null || value === '') return 0;
  171. const numeric = Number(value);
  172. if (Number.isFinite(numeric)) {
  173. return Math.trunc(numeric > 1e11 ? numeric / 1000 : numeric);
  174. }
  175. const parsed = Date.parse(String(value));
  176. return Number.isFinite(parsed) ? Math.trunc(parsed / 1000) : 0;
  177. }
  178. function buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt) {
  179. const normalizedAccountId = normalizeString(accountId);
  180. if (!normalizedAccountId) return '';
  181. const now = Math.trunc(Date.now() / 1000);
  182. const expires = epochSecondsFromValue(expiresAt) || now + 90 * 24 * 60 * 60;
  183. const authInfo = { chatgpt_account_id: normalizedAccountId };
  184. if (planType) authInfo.chatgpt_plan_type = normalizeString(planType);
  185. if (userId) {
  186. authInfo.chatgpt_user_id = normalizeString(userId);
  187. authInfo.user_id = normalizeString(userId);
  188. }
  189. const payload = {
  190. iat: now,
  191. exp: expires,
  192. 'https://api.openai.com/auth': authInfo,
  193. };
  194. if (email) payload.email = normalizeString(email);
  195. return `${encodeBase64UrlJson({ alg: 'none', typ: 'JWT', cpa_synthetic: true })}.${encodeBase64UrlJson(payload)}.synthetic`;
  196. }
  197. function normalizePlanTypeForFileName(planType = '') {
  198. return normalizeString(planType)
  199. .split(/[^a-zA-Z0-9]+/)
  200. .map((part) => part.trim().toLowerCase())
  201. .filter(Boolean)
  202. .join('-');
  203. }
  204. function sanitizeFileSegment(value = '', fallback = 'chatgpt-session') {
  205. const normalized = normalizeString(value)
  206. .replace(/[\\/:*?"<>|]+/g, '-')
  207. .replace(/\s+/g, '-')
  208. .replace(/-+/g, '-')
  209. .replace(/^-+|-+$/g, '');
  210. return normalized || fallback;
  211. }
  212. function buildCpaAuthFileName(metadata = {}) {
  213. const email = sanitizeFileSegment(metadata.email || '');
  214. const planType = normalizePlanTypeForFileName(metadata.planType || '');
  215. const accountId = sanitizeFileSegment(metadata.accountId || '');
  216. if (email && planType) return `codex-${email}-${planType}.json`;
  217. if (email) return `codex-${email}.json`;
  218. if (accountId && planType) return `codex-${accountId}-${planType}.json`;
  219. if (accountId) return `codex-${accountId}.json`;
  220. return `codex-${Date.now()}.json`;
  221. }
  222. function buildCpaSessionAuthJson(state = {}, options = {}) {
  223. const session = isPlainObject(state?.session) ? state.session : {};
  224. const accessToken = normalizeString(state?.accessToken || session?.accessToken);
  225. if (!accessToken) {
  226. throw new Error('未读取到可导入的 ChatGPT accessToken。');
  227. }
  228. const inputIdToken = firstNonEmpty(
  229. state?.idToken,
  230. state?.id_token,
  231. session?.idToken,
  232. session?.id_token
  233. );
  234. const refreshToken = firstNonEmpty(
  235. state?.refreshToken,
  236. state?.refresh_token,
  237. session?.refreshToken,
  238. session?.refresh_token
  239. );
  240. const sessionToken = firstNonEmpty(
  241. state?.sessionToken,
  242. state?.session_token,
  243. session?.sessionToken,
  244. session?.session_token
  245. );
  246. const accessPayload = parseJwtPayload(accessToken);
  247. const idPayload = parseJwtPayload(inputIdToken);
  248. const accessAuth = getOpenAiAuthSection(accessPayload);
  249. const idAuth = getOpenAiAuthSection(idPayload);
  250. const profile = getOpenAiProfileSection(accessPayload);
  251. const expiresAt = firstNonEmpty(
  252. timestampFromUnixSeconds(accessPayload?.exp),
  253. normalizeTimestamp(session?.expires),
  254. normalizeTimestamp(session?.expiresAt),
  255. normalizeTimestamp(session?.expired),
  256. normalizeTimestamp(session?.expires_at)
  257. );
  258. const accountIdentifierEmail = normalizeString(state?.accountIdentifierType).toLowerCase() === 'email'
  259. ? normalizeEmailValue(state?.accountIdentifier)
  260. : '';
  261. const email = firstNonEmpty(
  262. normalizeEmailValue(session?.user?.email),
  263. normalizeEmailValue(session?.email),
  264. normalizeEmailValue(state?.email),
  265. accountIdentifierEmail,
  266. normalizeEmailValue(profile?.email),
  267. normalizeEmailValue(idPayload?.email),
  268. normalizeEmailValue(accessPayload?.email)
  269. );
  270. const accountId = firstNonEmpty(
  271. session?.account?.id,
  272. session?.account_id,
  273. accessAuth?.chatgpt_account_id,
  274. idAuth?.chatgpt_account_id
  275. );
  276. const userId = firstNonEmpty(
  277. session?.user?.id,
  278. session?.user_id,
  279. accessAuth?.chatgpt_user_id,
  280. accessAuth?.user_id,
  281. idAuth?.chatgpt_user_id,
  282. idAuth?.user_id
  283. );
  284. const planType = firstNonEmpty(
  285. session?.account?.planType,
  286. session?.account?.plan_type,
  287. session?.planType,
  288. session?.plan_type,
  289. accessAuth?.chatgpt_plan_type,
  290. idAuth?.chatgpt_plan_type
  291. );
  292. const exportedAt = normalizeTimestamp(options.now || new Date()) || new Date().toISOString();
  293. const syntheticIdToken = inputIdToken
  294. ? ''
  295. : buildSyntheticCodexIdToken(email, accountId, planType, userId, expiresAt);
  296. const idToken = inputIdToken || syntheticIdToken;
  297. const authJson = Object.fromEntries(
  298. Object.entries({
  299. type: 'codex',
  300. account_id: accountId,
  301. chatgpt_account_id: accountId,
  302. email,
  303. name: firstNonEmpty(email, state?.email, 'ChatGPT Account'),
  304. plan_type: planType,
  305. chatgpt_plan_type: planType,
  306. id_token: idToken,
  307. id_token_synthetic: syntheticIdToken ? true : undefined,
  308. access_token: accessToken,
  309. refresh_token: refreshToken || '',
  310. session_token: sessionToken,
  311. last_refresh: exportedAt,
  312. expired: expiresAt,
  313. disabled: session?.disabled === true ? true : undefined,
  314. }).filter(([, value]) => value !== undefined && value !== null && value !== '')
  315. );
  316. return {
  317. authJson,
  318. accountId,
  319. email,
  320. expiresAt,
  321. fileName: buildCpaAuthFileName({ email, planType, accountId }),
  322. hasRefreshToken: Boolean(refreshToken),
  323. };
  324. }
  325. async function logWithOptions(message, level = 'info', options = {}) {
  326. await addLog(message, level, options.logOptions || {});
  327. }
  328. async function importCurrentChatGptSession(state = {}, options = {}) {
  329. const logLabel = normalizeString(options.logLabel) || 'CPA 会话导入';
  330. const managementKey = normalizeString(state?.vpsPassword);
  331. if (!managementKey) {
  332. throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
  333. }
  334. const origin = deriveCpaManagementOrigin(state?.vpsUrl);
  335. const sessionAuth = buildCpaSessionAuthJson(state, options);
  336. await logWithOptions(`${logLabel}:正在通过 CPA 管理接口导入当前 ChatGPT 会话...`, 'info', options);
  337. if (!sessionAuth.hasRefreshToken) {
  338. await logWithOptions(`${logLabel}:未包含 refresh_token,access_token 过期后无法自动续期。`, 'warn', options);
  339. }
  340. await fetchCpaManagementJson(origin, `/v0/management/auth-files?name=${encodeURIComponent(sessionAuth.fileName)}`, {
  341. method: 'POST',
  342. managementKey,
  343. timeoutMs: options.importTimeoutMs || options.timeoutMs,
  344. body: sessionAuth.authJson,
  345. });
  346. const verifiedStatus = sessionAuth.email
  347. ? `CPA 会话导入完成:${sessionAuth.email}`
  348. : `CPA 会话导入完成:${sessionAuth.fileName}`;
  349. await logWithOptions(verifiedStatus, 'ok', options);
  350. return {
  351. verifiedStatus,
  352. cpaImportedFileName: sessionAuth.fileName,
  353. cpaImportedEmail: sessionAuth.email || null,
  354. };
  355. }
  356. return {
  357. buildCpaSessionAuthJson,
  358. deriveCpaManagementOrigin,
  359. fetchCpaManagementJson,
  360. importCurrentChatGptSession,
  361. };
  362. }
  363. return {
  364. createCpaApi,
  365. };
  366. });