sub2api-panel.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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_REDIRECT_URI = 'http://localhost:1455/auth/callback';
  6. const SUB2API_DEFAULT_CONCURRENCY = 10;
  7. const SUB2API_DEFAULT_PRIORITY = 1;
  8. const SUB2API_DEFAULT_RATE_MULTIPLIER = 1;
  9. if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '1') {
  10. document.documentElement.setAttribute(SUB2API_PANEL_LISTENER_SENTINEL, '1');
  11. chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  12. if (message.type === 'EXECUTE_STEP') {
  13. resetStopState();
  14. handleStep(message.step, message.payload).then(() => {
  15. sendResponse({ ok: true });
  16. }).catch((err) => {
  17. if (isStopError(err)) {
  18. log(`步骤 ${message.step}:已被用户停止。`, 'warn');
  19. sendResponse({ stopped: true, error: err.message });
  20. return;
  21. }
  22. reportError(message.step, err.message);
  23. sendResponse({ error: err.message });
  24. });
  25. return true;
  26. }
  27. });
  28. } else {
  29. console.log('[MultiPage:sub2api-panel] 消息监听已存在,跳过重复注册');
  30. }
  31. function getSub2ApiOrigin(payload = {}) {
  32. const rawUrl = payload.sub2apiUrl || location.href;
  33. try {
  34. return new URL(rawUrl).origin;
  35. } catch {
  36. return location.origin;
  37. }
  38. }
  39. function normalizeRedirectUri(rawUrl) {
  40. const input = (rawUrl || '').trim() || SUB2API_DEFAULT_REDIRECT_URI;
  41. const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`;
  42. const parsed = new URL(withProtocol);
  43. if (!parsed.pathname || parsed.pathname === '/') {
  44. parsed.pathname = '/auth/callback';
  45. }
  46. if (parsed.pathname !== '/auth/callback') {
  47. throw new Error('SUB2API 回调地址必须是 /auth/callback,例如 http://localhost:1455/auth/callback');
  48. }
  49. return parsed.toString();
  50. }
  51. async function handleStep(step, payload = {}) {
  52. switch (step) {
  53. case 1:
  54. return step1_generateOpenAiAuthUrl(payload);
  55. case 9:
  56. return step9_submitOpenAiCallback(payload);
  57. default:
  58. throw new Error(`sub2api-panel.js 不处理步骤 ${step}`);
  59. }
  60. }
  61. async function requestJson(origin, path, options = {}) {
  62. throwIfStopped();
  63. const {
  64. method = 'GET',
  65. token = '',
  66. body = undefined,
  67. } = options;
  68. const response = await fetch(`${origin}${path}`, {
  69. method,
  70. credentials: 'same-origin',
  71. headers: {
  72. 'Content-Type': 'application/json',
  73. ...(token ? { Authorization: `Bearer ${token}` } : {}),
  74. },
  75. body: body === undefined ? undefined : JSON.stringify(body),
  76. });
  77. const text = await response.text();
  78. let json = null;
  79. try {
  80. json = text ? JSON.parse(text) : null;
  81. } catch {
  82. json = null;
  83. }
  84. if (json && typeof json === 'object' && 'code' in json) {
  85. if (json.code === 0) {
  86. return json.data;
  87. }
  88. throw new Error(json.message || json.detail || `请求失败(${path})`);
  89. }
  90. if (!response.ok) {
  91. throw new Error((json && (json.message || json.detail)) || `请求失败(HTTP ${response.status}):${path}`);
  92. }
  93. return json;
  94. }
  95. function storeAuthSession(loginData) {
  96. if (!loginData?.access_token) {
  97. throw new Error('SUB2API 登录返回缺少 access_token。');
  98. }
  99. localStorage.setItem('auth_token', loginData.access_token);
  100. if (loginData.refresh_token) {
  101. localStorage.setItem('refresh_token', loginData.refresh_token);
  102. } else {
  103. localStorage.removeItem('refresh_token');
  104. }
  105. if (loginData.expires_in) {
  106. localStorage.setItem('token_expires_at', String(Date.now() + Number(loginData.expires_in) * 1000));
  107. }
  108. if (loginData.user) {
  109. localStorage.setItem('auth_user', JSON.stringify(loginData.user));
  110. }
  111. sessionStorage.removeItem('auth_expired');
  112. }
  113. async function loginSub2Api(payload = {}) {
  114. const email = (payload.sub2apiEmail || '').trim();
  115. const password = payload.sub2apiPassword || '';
  116. const origin = getSub2ApiOrigin(payload);
  117. if (!email) {
  118. throw new Error('缺少 SUB2API 登录邮箱,请先在侧边栏填写。');
  119. }
  120. if (!password) {
  121. throw new Error('缺少 SUB2API 登录密码,请先在侧边栏填写。');
  122. }
  123. log('步骤:正在登录 SUB2API 后台...');
  124. const loginData = await requestJson(origin, '/api/v1/auth/login', {
  125. method: 'POST',
  126. body: {
  127. email,
  128. password,
  129. },
  130. });
  131. storeAuthSession(loginData);
  132. return {
  133. origin,
  134. token: loginData.access_token,
  135. user: loginData.user || null,
  136. };
  137. }
  138. async function getGroupByName(origin, token, groupName) {
  139. const targetName = (groupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME;
  140. const groups = await requestJson(origin, '/api/v1/admin/groups/all', {
  141. method: 'GET',
  142. token,
  143. });
  144. const normalized = targetName.toLowerCase();
  145. const group = (groups || []).find((item) => {
  146. const itemName = String(item?.name || '').trim().toLowerCase();
  147. if (!itemName) return false;
  148. if (itemName !== normalized) return false;
  149. return !item.platform || item.platform === 'openai';
  150. });
  151. if (!group) {
  152. throw new Error(`SUB2API 中未找到名为“${targetName}”的 openai 分组。`);
  153. }
  154. return group;
  155. }
  156. function buildDraftAccountName(groupName) {
  157. const prefix = (groupName || SUB2API_DEFAULT_GROUP_NAME)
  158. .trim()
  159. .replace(/[^\w\u4e00-\u9fa5-]+/g, '-')
  160. .replace(/^-+|-+$/g, '') || SUB2API_DEFAULT_GROUP_NAME;
  161. const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14);
  162. const random = Math.floor(Math.random() * 9000 + 1000);
  163. return `${prefix}-${stamp}-${random}`;
  164. }
  165. function extractStateFromAuthUrl(authUrl) {
  166. try {
  167. return new URL(authUrl).searchParams.get('state') || '';
  168. } catch {
  169. return '';
  170. }
  171. }
  172. function parseLocalhostCallback(rawUrl) {
  173. let parsed;
  174. try {
  175. parsed = new URL(rawUrl);
  176. } catch {
  177. throw new Error('提供的回调 URL 不是合法链接。');
  178. }
  179. if (!['http:', 'https:'].includes(parsed.protocol)) {
  180. throw new Error('回调 URL 协议不正确。');
  181. }
  182. if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) {
  183. throw new Error('步骤 9 只接受 localhost / 127.0.0.1 回调地址。');
  184. }
  185. if (parsed.pathname !== '/auth/callback') {
  186. throw new Error('回调 URL 路径必须是 /auth/callback。');
  187. }
  188. const code = (parsed.searchParams.get('code') || '').trim();
  189. const state = (parsed.searchParams.get('state') || '').trim();
  190. if (!code || !state) {
  191. throw new Error('回调 URL 中缺少 code 或 state。');
  192. }
  193. return {
  194. url: parsed.toString(),
  195. code,
  196. state,
  197. };
  198. }
  199. function buildOpenAiCredentials(exchangeData) {
  200. const credentials = {};
  201. const allowedKeys = [
  202. 'access_token',
  203. 'refresh_token',
  204. 'id_token',
  205. 'expires_at',
  206. 'email',
  207. 'chatgpt_account_id',
  208. 'chatgpt_user_id',
  209. 'organization_id',
  210. 'plan_type',
  211. 'client_id',
  212. ];
  213. for (const key of allowedKeys) {
  214. if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') {
  215. credentials[key] = exchangeData[key];
  216. }
  217. }
  218. if (!credentials.access_token) {
  219. throw new Error('SUB2API 交换授权码后未返回 access_token。');
  220. }
  221. return credentials;
  222. }
  223. function buildOpenAiExtra(exchangeData) {
  224. const extra = {};
  225. const allowedKeys = ['email', 'name', 'privacy_mode'];
  226. for (const key of allowedKeys) {
  227. if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') {
  228. extra[key] = exchangeData[key];
  229. }
  230. }
  231. return Object.keys(extra).length ? extra : undefined;
  232. }
  233. async function getBackgroundState() {
  234. try {
  235. return await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sub2api-panel' });
  236. } catch {
  237. return {};
  238. }
  239. }
  240. function openAccountsPageSoon(origin) {
  241. const accountsUrl = `${origin}/admin/accounts`;
  242. if (location.href === accountsUrl || location.pathname.startsWith('/admin/accounts')) {
  243. return;
  244. }
  245. setTimeout(() => {
  246. try {
  247. location.replace(accountsUrl);
  248. } catch { }
  249. }, 500);
  250. }
  251. async function step1_generateOpenAiAuthUrl(payload = {}) {
  252. const redirectUri = normalizeRedirectUri(payload.sub2apiRedirectUri);
  253. const groupName = (payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME;
  254. const { origin, token } = await loginSub2Api(payload);
  255. const group = await getGroupByName(origin, token, groupName);
  256. const draftName = buildDraftAccountName(group.name || groupName);
  257. log(`步骤 1:已登录 SUB2API,使用分组 ${group.name}(#${group.id})。`);
  258. log(`步骤 1:正在向 SUB2API 生成 OpenAI Auth 链接,回调地址为 ${redirectUri}。`);
  259. const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', {
  260. method: 'POST',
  261. token,
  262. body: {
  263. redirect_uri: redirectUri,
  264. },
  265. });
  266. const oauthUrl = String(authData?.auth_url || '').trim();
  267. const sessionId = String(authData?.session_id || '').trim();
  268. const oauthState = String(authData?.state || extractStateFromAuthUrl(oauthUrl)).trim();
  269. if (!oauthUrl || !sessionId) {
  270. throw new Error('SUB2API 未返回完整的 auth_url / session_id。');
  271. }
  272. log(`步骤 1:已获取 SUB2API OAuth 链接:${oauthUrl.slice(0, 96)}...`, 'ok');
  273. reportComplete(1, {
  274. oauthUrl,
  275. sub2apiSessionId: sessionId,
  276. sub2apiOAuthState: oauthState,
  277. sub2apiGroupId: group.id,
  278. sub2apiDraftName: draftName,
  279. });
  280. openAccountsPageSoon(origin);
  281. }
  282. async function step9_submitOpenAiCallback(payload = {}) {
  283. const callback = parseLocalhostCallback(payload.localhostUrl || '');
  284. const backgroundState = await getBackgroundState();
  285. const flowEmail = String(backgroundState.email || '').trim();
  286. const sessionId = String(payload.sub2apiSessionId || backgroundState.sub2apiSessionId || '').trim();
  287. const expectedState = String(payload.sub2apiOAuthState || backgroundState.sub2apiOAuthState || '').trim();
  288. const accountName = flowEmail
  289. || String(payload.sub2apiDraftName || backgroundState.sub2apiDraftName || '').trim()
  290. || buildDraftAccountName(payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
  291. const { origin, token } = await loginSub2Api(payload);
  292. const group = payload.sub2apiGroupId
  293. ? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }
  294. : await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
  295. if (!sessionId) {
  296. throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。');
  297. }
  298. if (expectedState && expectedState !== callback.state) {
  299. throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。');
  300. }
  301. log('步骤 9:正在向 SUB2API 交换 OpenAI 授权码...');
  302. const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', {
  303. method: 'POST',
  304. token,
  305. body: {
  306. session_id: sessionId,
  307. code: callback.code,
  308. state: callback.state,
  309. },
  310. });
  311. const credentials = buildOpenAiCredentials(exchangeData);
  312. const extra = buildOpenAiExtra(exchangeData);
  313. const groupId = Number(group.id);
  314. if (!Number.isFinite(groupId) || groupId <= 0) {
  315. throw new Error('SUB2API 返回的目标分组 ID 无效。');
  316. }
  317. const createPayload = {
  318. name: accountName,
  319. notes: '',
  320. platform: 'openai',
  321. type: 'oauth',
  322. credentials,
  323. concurrency: SUB2API_DEFAULT_CONCURRENCY,
  324. priority: SUB2API_DEFAULT_PRIORITY,
  325. rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
  326. group_ids: [groupId],
  327. auto_pause_on_expired: true,
  328. };
  329. if (extra) {
  330. createPayload.extra = extra;
  331. }
  332. log(`步骤 9:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`);
  333. const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', {
  334. method: 'POST',
  335. token,
  336. body: createPayload,
  337. });
  338. const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
  339. log(`步骤 9:${verifiedStatus}`, 'ok');
  340. reportComplete(9, {
  341. localhostUrl: callback.url,
  342. verifiedStatus,
  343. });
  344. openAccountsPageSoon(origin);
  345. }
  346. reportReady();