sub2api-panel.js 13 KB

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