sub2api-panel.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. // content/sub2api-panel.js — 页内脚本:SUB2API 后台(步骤 1、9)
  2. console.log('[MultiPage:sub2api-panel] Content script loaded on', location.href);
  3. const SUB2API_PANEL_LISTENER_SENTINEL = 'data-multipage-sub2api-panel-listener';
  4. const SUB2API_DEFAULT_GROUP_NAME = 'codex';
  5. const SUB2API_DEFAULT_PROXY_NAME = 'shadowrocket';
  6. const SUB2API_DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback';
  7. const SUB2API_DEFAULT_CONCURRENCY = 10;
  8. const SUB2API_DEFAULT_PRIORITY = 1;
  9. const SUB2API_DEFAULT_RATE_MULTIPLIER = 1;
  10. if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '1') {
  11. document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1');
  12. chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  13. if (message.type === 'EXECUTE_STEP' || message.type === 'REQUEST_OAUTH_URL') {
  14. resetStopState();
  15. const handler = message.type === 'REQUEST_OAUTH_URL'
  16. ? requestOAuthUrl(message.payload)
  17. : handleStep(message.step, message.payload);
  18. handler.then((result) => {
  19. sendResponse({ ok: true, ...(result || {}) });
  20. }).catch((err) => {
  21. if (isStopError(err)) {
  22. if (message.step) {
  23. log(`步骤 ${message.step}:已被用户停止。`, 'warn');
  24. }
  25. sendResponse({ stopped: true, error: err.message });
  26. return;
  27. }
  28. if (message.step) {
  29. reportError(message.step, err.message);
  30. }
  31. sendResponse({ error: err.message });
  32. });
  33. return true;
  34. }
  35. });
  36. } else {
  37. console.log('[MultiPage:sub2api-panel] 消息监听已存在,跳过重复注册');
  38. }
  39. function getSub2ApiOrigin(payload = {}) {
  40. const rawUrl = payload.sub2apiUrl || location.href;
  41. try {
  42. return new URL(rawUrl).origin;
  43. } catch {
  44. return location.origin;
  45. }
  46. }
  47. function normalizeRedirectUri() {
  48. const input = SUB2API_DEFAULT_REDIRECT_URI;
  49. const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`;
  50. const parsed = new URL(withProtocol);
  51. if (!parsed.pathname || parsed.pathname === '/') {
  52. parsed.pathname = '/auth/callback';
  53. }
  54. if (parsed.pathname !== '/auth/callback') {
  55. throw new Error('SUB2API 回调地址必须是 /auth/callback,例如 http://localhost:1455/auth/callback');
  56. }
  57. return parsed.toString();
  58. }
  59. async function handleStep(step, payload = {}) {
  60. switch (step) {
  61. case 1:
  62. return step1_generateOpenAiAuthUrl(payload);
  63. case 10:
  64. return step9_submitOpenAiCallback(payload);
  65. default:
  66. throw new Error(`sub2api-panel.js 不处理步骤 ${step}`);
  67. }
  68. }
  69. async function requestOAuthUrl(payload = {}) {
  70. return step1_generateOpenAiAuthUrl(payload, { report: false });
  71. }
  72. async function requestJson(origin, path, options = {}) {
  73. throwIfStopped();
  74. const {
  75. method = 'GET',
  76. token = '',
  77. body = undefined,
  78. } = options;
  79. const response = await fetch(`${origin}${path}`, {
  80. method,
  81. credentials: 'same-origin',
  82. headers: {
  83. 'Content-Type': 'application/json',
  84. ...(token ? { Authorization: `Bearer ${token}` } : {}),
  85. },
  86. body: body === undefined ? undefined : JSON.stringify(body),
  87. });
  88. const text = await response.text();
  89. let json = null;
  90. try {
  91. json = text ? JSON.parse(text) : null;
  92. } catch {
  93. json = null;
  94. }
  95. if (json && typeof json === 'object' && 'code' in json) {
  96. if (json.code === 0) {
  97. return json.data;
  98. }
  99. throw new Error(json.message || json.detail || `请求失败(${path})`);
  100. }
  101. if (!response.ok) {
  102. throw new Error((json && (json.message || json.detail)) || `请求失败(HTTP ${response.status}):${path}`);
  103. }
  104. return json;
  105. }
  106. function storeAuthSession(loginData) {
  107. if (!loginData?.access_token) {
  108. throw new Error('SUB2API 登录返回缺少 access_token。');
  109. }
  110. localStorage.setItem('auth_token', loginData.access_token);
  111. if (loginData.refresh_token) {
  112. localStorage.setItem('refresh_token', loginData.refresh_token);
  113. } else {
  114. localStorage.removeItem('refresh_token');
  115. }
  116. if (loginData.expires_in) {
  117. localStorage.setItem('token_expires_at', String(Date.now() + Number(loginData.expires_in) * 1000));
  118. }
  119. if (loginData.user) {
  120. localStorage.setItem('auth_user', JSON.stringify(loginData.user));
  121. }
  122. sessionStorage.removeItem('auth_expired');
  123. }
  124. async function loginSub2Api(payload = {}) {
  125. const email = (payload.sub2apiEmail || '').trim();
  126. const password = payload.sub2apiPassword || '';
  127. const origin = getSub2ApiOrigin(payload);
  128. if (!email) {
  129. throw new Error('缺少 SUB2API 登录邮箱,请先在侧边栏填写。');
  130. }
  131. if (!password) {
  132. throw new Error('缺少 SUB2API 登录密码,请先在侧边栏填写。');
  133. }
  134. log('步骤:正在登录 SUB2API 后台...');
  135. const loginData = await requestJson(origin, '/api/v1/auth/login', {
  136. method: 'POST',
  137. body: {
  138. email,
  139. password,
  140. },
  141. });
  142. storeAuthSession(loginData);
  143. return {
  144. origin,
  145. token: loginData.access_token,
  146. user: loginData.user || null,
  147. };
  148. }
  149. async function getGroupByName(origin, token, groupName) {
  150. const targetName = (groupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME;
  151. const groups = await requestJson(origin, '/api/v1/admin/groups/all', {
  152. method: 'GET',
  153. token,
  154. });
  155. const normalized = targetName.toLowerCase();
  156. const group = (groups || []).find((item) => {
  157. const itemName = String(item?.name || '').trim().toLowerCase();
  158. if (!itemName) return false;
  159. if (itemName !== normalized) return false;
  160. return !item.platform || item.platform === 'openai';
  161. });
  162. if (!group) {
  163. throw new Error(`SUB2API 中未找到名为“${targetName}”的 openai 分组。`);
  164. }
  165. return group;
  166. }
  167. function normalizeSub2ApiProxyPreference(value) {
  168. return String(value || '').trim();
  169. }
  170. function resolveSub2ApiProxyPreference(payload = {}, backgroundState = {}) {
  171. if (payload.sub2apiDefaultProxyName !== undefined) {
  172. return normalizeSub2ApiProxyPreference(payload.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME;
  173. }
  174. if (backgroundState.sub2apiDefaultProxyName !== undefined) {
  175. return normalizeSub2ApiProxyPreference(backgroundState.sub2apiDefaultProxyName) || SUB2API_DEFAULT_PROXY_NAME;
  176. }
  177. return SUB2API_DEFAULT_PROXY_NAME;
  178. }
  179. function normalizeProxyId(value) {
  180. if (value === undefined || value === null || value === '') {
  181. return null;
  182. }
  183. const normalized = Number(value);
  184. if (!Number.isSafeInteger(normalized) || normalized <= 0) {
  185. return null;
  186. }
  187. return normalized;
  188. }
  189. function buildProxyDisplayName(proxy = {}) {
  190. const id = normalizeProxyId(proxy.id);
  191. const name = String(proxy.name || '').trim();
  192. const protocol = String(proxy.protocol || '').trim();
  193. const host = String(proxy.host || '').trim();
  194. const port = proxy.port === undefined || proxy.port === null ? '' : String(proxy.port).trim();
  195. const address = protocol && host && port ? `${protocol}://${host}:${port}` : '';
  196. const parts = [
  197. name || '(未命名代理)',
  198. id ? `#${id}` : '',
  199. address,
  200. ].filter(Boolean);
  201. return parts.join(' ');
  202. }
  203. function buildProxySearchText(proxy = {}) {
  204. return [
  205. proxy.id,
  206. proxy.name,
  207. proxy.protocol,
  208. proxy.host,
  209. proxy.port,
  210. buildProxyDisplayName(proxy),
  211. ]
  212. .filter((value) => value !== undefined && value !== null && value !== '')
  213. .map((value) => String(value).trim().toLowerCase())
  214. .filter(Boolean)
  215. .join(' ');
  216. }
  217. function isActiveProxy(proxy = {}) {
  218. const status = String(proxy.status || '').trim().toLowerCase();
  219. return !status || status === 'active';
  220. }
  221. function findSub2ApiProxy(proxies = [], preference = '') {
  222. const activeProxies = (Array.isArray(proxies) ? proxies : [])
  223. .filter(isActiveProxy)
  224. .filter((proxy) => normalizeProxyId(proxy.id));
  225. const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase();
  226. const preferredId = normalizeProxyId(normalizedPreference);
  227. if (preferredId) {
  228. const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId);
  229. return {
  230. proxy: matchedById || null,
  231. reason: matchedById ? 'id' : 'missing-id',
  232. candidates: activeProxies,
  233. };
  234. }
  235. if (normalizedPreference) {
  236. const exactMatches = activeProxies.filter((proxy) => {
  237. const name = String(proxy.name || '').trim().toLowerCase();
  238. return name === normalizedPreference;
  239. });
  240. if (exactMatches.length === 1) {
  241. return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies };
  242. }
  243. if (exactMatches.length > 1) {
  244. return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches };
  245. }
  246. const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference));
  247. if (fuzzyMatches.length === 1) {
  248. return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies };
  249. }
  250. if (fuzzyMatches.length > 1) {
  251. return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches };
  252. }
  253. return { proxy: null, reason: 'missing-name', candidates: activeProxies };
  254. }
  255. if (activeProxies.length === 1) {
  256. return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies };
  257. }
  258. return {
  259. proxy: null,
  260. reason: activeProxies.length ? 'no-preference' : 'none-active',
  261. candidates: activeProxies,
  262. };
  263. }
  264. async function resolveSub2ApiProxy(origin, token, preference = '') {
  265. const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', {
  266. method: 'GET',
  267. token,
  268. });
  269. if (!Array.isArray(proxies)) {
  270. throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。');
  271. }
  272. const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference);
  273. if (proxy) {
  274. return proxy;
  275. }
  276. const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)';
  277. const available = (candidates || [])
  278. .slice(0, 8)
  279. .map(buildProxyDisplayName)
  280. .join(',') || '无可用代理';
  281. if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') {
  282. throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`);
  283. }
  284. if (reason === 'missing-id') {
  285. throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`);
  286. }
  287. if (reason === 'missing-name') {
  288. throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`);
  289. }
  290. if (reason === 'no-preference') {
  291. throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID。可用代理:${available}`);
  292. }
  293. throw new Error('SUB2API 没有可用代理;当前流程要求账号必须绑定代理。');
  294. }
  295. function buildDraftAccountName(groupName) {
  296. const prefix = (groupName || SUB2API_DEFAULT_GROUP_NAME)
  297. .trim()
  298. .replace(/[^\w\u4e00-\u9fa5-]+/g, '-')
  299. .replace(/^-+|-+$/g, '') || SUB2API_DEFAULT_GROUP_NAME;
  300. const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14);
  301. const random = Math.floor(Math.random() * 9000 + 1000);
  302. return `${prefix}-${stamp}-${random}`;
  303. }
  304. function extractStateFromAuthUrl(authUrl) {
  305. try {
  306. return new URL(authUrl).searchParams.get('state') || '';
  307. } catch {
  308. return '';
  309. }
  310. }
  311. function parseLocalhostCallback(rawUrl) {
  312. let parsed;
  313. try {
  314. parsed = new URL(rawUrl);
  315. } catch {
  316. throw new Error('提供的回调 URL 不是合法链接。');
  317. }
  318. if (!['http:', 'https:'].includes(parsed.protocol)) {
  319. throw new Error('回调 URL 协议不正确。');
  320. }
  321. if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) {
  322. throw new Error('步骤 10 只接受 localhost / 127.0.0.1 回调地址。');
  323. }
  324. if (parsed.pathname !== '/auth/callback') {
  325. throw new Error('回调 URL 路径必须是 /auth/callback。');
  326. }
  327. const code = (parsed.searchParams.get('code') || '').trim();
  328. const state = (parsed.searchParams.get('state') || '').trim();
  329. if (!code || !state) {
  330. throw new Error('回调 URL 中缺少 code 或 state。');
  331. }
  332. return {
  333. url: parsed.toString(),
  334. code,
  335. state,
  336. };
  337. }
  338. function buildOpenAiCredentials(exchangeData) {
  339. const credentials = {};
  340. const allowedKeys = [
  341. 'access_token',
  342. 'refresh_token',
  343. 'id_token',
  344. 'expires_at',
  345. 'email',
  346. 'chatgpt_account_id',
  347. 'chatgpt_user_id',
  348. 'organization_id',
  349. 'plan_type',
  350. 'client_id',
  351. ];
  352. for (const key of allowedKeys) {
  353. if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') {
  354. credentials[key] = exchangeData[key];
  355. }
  356. }
  357. if (!credentials.access_token) {
  358. throw new Error('SUB2API 交换授权码后未返回 access_token。');
  359. }
  360. return credentials;
  361. }
  362. function buildOpenAiExtra(exchangeData) {
  363. const extra = {};
  364. const allowedKeys = ['email', 'name', 'privacy_mode'];
  365. for (const key of allowedKeys) {
  366. if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') {
  367. extra[key] = exchangeData[key];
  368. }
  369. }
  370. return Object.keys(extra).length ? extra : undefined;
  371. }
  372. async function getBackgroundState() {
  373. try {
  374. return await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sub2api-panel' });
  375. } catch {
  376. return {};
  377. }
  378. }
  379. function openAccountsPageSoon(origin) {
  380. const accountsUrl = `${origin}/admin/accounts`;
  381. if (location.href === accountsUrl || location.pathname.startsWith('/admin/accounts')) {
  382. return;
  383. }
  384. setTimeout(() => {
  385. try {
  386. location.replace(accountsUrl);
  387. } catch { }
  388. }, 500);
  389. }
  390. async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
  391. const { report = true } = options;
  392. const logStep = Number.isInteger(payload?.logStep) ? payload.logStep : 1;
  393. const redirectUri = normalizeRedirectUri();
  394. const groupName = (payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME;
  395. const { origin, token } = await loginSub2Api(payload);
  396. const group = await getGroupByName(origin, token, groupName);
  397. const proxyPreference = resolveSub2ApiProxyPreference(payload);
  398. const proxy = await resolveSub2ApiProxy(origin, token, proxyPreference);
  399. const proxyId = normalizeProxyId(proxy.id);
  400. const draftName = buildDraftAccountName(group.name || groupName);
  401. log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${group.name}(#${group.id})。`);
  402. log(`步骤 ${logStep}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
  403. log(`步骤 ${logStep}:正在向 SUB2API 生成 OpenAI Auth 链接,回调地址为 ${redirectUri}。`);
  404. const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', {
  405. method: 'POST',
  406. token,
  407. body: {
  408. redirect_uri: redirectUri,
  409. proxy_id: proxyId,
  410. },
  411. });
  412. const oauthUrl = String(authData?.auth_url || '').trim();
  413. const sessionId = String(authData?.session_id || '').trim();
  414. const oauthState = String(authData?.state || extractStateFromAuthUrl(oauthUrl)).trim();
  415. if (!oauthUrl || !sessionId) {
  416. throw new Error('SUB2API 未返回完整的 auth_url / session_id。');
  417. }
  418. log(`步骤 ${logStep}:已获取 SUB2API OAuth 链接:${oauthUrl.slice(0, 96)}...`, 'ok');
  419. const result = {
  420. oauthUrl,
  421. sub2apiSessionId: sessionId,
  422. sub2apiOAuthState: oauthState,
  423. sub2apiGroupId: group.id,
  424. sub2apiDraftName: draftName,
  425. sub2apiProxyId: proxyId,
  426. };
  427. if (report) {
  428. reportComplete(1, result);
  429. }
  430. openAccountsPageSoon(origin);
  431. return result;
  432. }
  433. async function step9_submitOpenAiCallback(payload = {}) {
  434. const callback = parseLocalhostCallback(payload.localhostUrl || '');
  435. const backgroundState = await getBackgroundState();
  436. const flowEmail = String(backgroundState.email || '').trim();
  437. const sessionId = String(payload.sub2apiSessionId || backgroundState.sub2apiSessionId || '').trim();
  438. const expectedState = String(payload.sub2apiOAuthState || backgroundState.sub2apiOAuthState || '').trim();
  439. const accountName = flowEmail
  440. || String(payload.sub2apiDraftName || backgroundState.sub2apiDraftName || '').trim()
  441. || buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
  442. const { origin, token } = await loginSub2Api(payload);
  443. const proxyPreference = resolveSub2ApiProxyPreference(payload, backgroundState);
  444. const preferredProxyId = normalizeProxyId(payload.sub2apiProxyId || backgroundState.sub2apiProxyId);
  445. const proxy = await resolveSub2ApiProxy(origin, token, preferredProxyId || proxyPreference);
  446. const proxyId = normalizeProxyId(proxy.id);
  447. const group = payload.sub2apiGroupId
  448. ? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }
  449. : await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
  450. if (!sessionId) {
  451. throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。');
  452. }
  453. if (expectedState && expectedState !== callback.state) {
  454. throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。');
  455. }
  456. log('步骤 10:正在向 SUB2API 交换 OpenAI 授权码...');
  457. log(`步骤 10:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`);
  458. const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', {
  459. method: 'POST',
  460. token,
  461. body: {
  462. session_id: sessionId,
  463. code: callback.code,
  464. state: callback.state,
  465. proxy_id: proxyId,
  466. },
  467. });
  468. const credentials = buildOpenAiCredentials(exchangeData);
  469. const extra = buildOpenAiExtra(exchangeData);
  470. const groupId = Number(group.id);
  471. if (!Number.isFinite(groupId) || groupId <= 0) {
  472. throw new Error('SUB2API 返回的目标分组 ID 无效。');
  473. }
  474. const createPayload = {
  475. name: accountName,
  476. notes: '',
  477. platform: 'openai',
  478. type: 'oauth',
  479. credentials,
  480. concurrency: SUB2API_DEFAULT_CONCURRENCY,
  481. priority: SUB2API_DEFAULT_PRIORITY,
  482. rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
  483. proxy_id: proxyId,
  484. group_ids: [groupId],
  485. auto_pause_on_expired: true,
  486. };
  487. if (extra) {
  488. createPayload.extra = extra;
  489. }
  490. log(`步骤 10:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`);
  491. const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', {
  492. method: 'POST',
  493. token,
  494. body: createPayload,
  495. });
  496. const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
  497. log(`步骤 10:${verifiedStatus}`, 'ok');
  498. reportComplete(10, {
  499. localhostUrl: callback.url,
  500. verifiedStatus,
  501. });
  502. openAccountsPageSoon(origin);
  503. }
  504. reportReady();