sub2api-panel.js 12 KB

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