import HotPocketExtension from '../common'; const POST_AUTH_PATH = '/integrations/extension/post-authenticate/'; const RPC_PATH = '/rpc/'; let authSessionToken = null; let rpcURL = null; const updateRpcURL = () => { rpcURL = null; if (HotPocketExtension.base_url !== null) { rpcURL = (new URL(RPC_PATH, HotPocketExtension.base_url)).toString(); } HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.updateRpcURL()', HotPocketExtension.base_url, rpcURL, ); }; const makeJSONRPCCall = (method, params) => { return { 'jsonrpc': '2.0', 'id': (new Date().toISOString()), 'method': method, 'params': params, }; }; const executeJSONRPCCall = async (url, call, {accessToken}) => { HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.executeJSONRPCCall()', url, call, accessToken, ); const headers = { 'Content-Type': 'application/json', }; if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; } let result = null; let error = null; const effectiveURL = new URL(url); effectiveURL.searchParams.append('method', call.method); try { const response = await fetch( effectiveURL.toString(), { body: JSON.stringify(call), credentials: 'include', headers: headers, method: 'POST', }, ); HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.executeJSONRPCCall()', response, ); if (response.status !== 200) { error = { code: -32000, message: 'Fetch error', data: response, }; } else { const callResult = await response.json(); if (callResult.error) { HotPocketExtension.LOGGER.error( 'HotPocketExtension.background.executeJSONRPCCall(): RPC error', callResult.error.code, callResult.error.message, callResult.error.data, ); error = callResult.error; } else { result = callResult.result; } } } catch (exception) { HotPocketExtension.LOGGER.error( 'HotPocketExtension.background.executeJSONRPCCall(): Fetch error', exception, ); error = { code: -32000, message: 'Fetch error', data: exception, }; } return [result, error]; }; const getAccessTokenMeta = () => { return { platform: navigator.platform, version: HotPocketExtension.version, }; }; const doSave = async (accessToken, tab) => { const call = makeJSONRPCCall('saves.create', [tab.url]); const [result, error] = await executeJSONRPCCall(rpcURL, call, {accessToken}); HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.doSave():', result, error, ); if (error !== null) { return false; } return true; }; const doCreateAndStoreAccessToken = async (authKey) => { const accessTokenCall = makeJSONRPCCall( 'accounts.access_tokens.create', [ authKey, getAccessTokenMeta(), ], ); const [accessToken, error] = await executeJSONRPCCall( rpcURL, accessTokenCall, {accessToken: null}, ); if (error === null) { await HotPocketExtension.api.storage.local.set({ accessToken: accessToken, }); } return [accessToken, error]; }; const doHandleAuthFlow = (authTab) => { HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.doHandleAuthFlow()', authTab, ); let currentAuthTabId = authTab.id; return new Promise((resolve, reject) => { const onTabsUpdated = (tabId, changeInfo, updatedTab) => { const changedURL = changeInfo.url; HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.doHandleAuthFlow.onTabsUpdated()', updatedTab, changedURL, (changedURL && changedURL.includes(POST_AUTH_PATH)), changeInfo, ); const expectedSessionTabQuery = `?authSessionToken=${authSessionToken}`; if (tabId !== currentAuthTabId && changedURL.includes(expectedSessionTabQuery)) { // When redirecting from the preauth page to the HotPocket instance, // Safari "replaces" the auth tab with a new one. This nasty hack will // allow the extension to keep track of it. // I hate computers. currentAuthTabId = tabId; } if (tabId === currentAuthTabId) { if (changedURL && changedURL.includes(POST_AUTH_PATH)) { const parsedChangedURL = new URL(changedURL); const authKey = parsedChangedURL.searchParams.get('auth_key'); doCreateAndStoreAccessToken(authKey). then((result) => { HotPocketExtension.LOGGER.debug( 'doHandleAuthFlow.onTabsUpdated.doGetAndStoreAccessToken.then()', result, ); const [accessToken, error] = result; if (error !== null) { reject(error); } else { resolve(accessToken); } }). catch((error) => { reject(error); }). finally(() => { authSessionToken = null; HotPocketExtension.api.tabs.onUpdated.removeListener(onTabsUpdated); HotPocketExtension.api.tabs.remove(currentAuthTabId); }); } } }; HotPocketExtension.api.tabs.onUpdated.addListener(onTabsUpdated); }); }; const doCheckAuth = async (accessToken) => { if (accessToken === null) { return null; } const call = makeJSONRPCCall( 'accounts.auth.check_access_token', [accessToken, getAccessTokenMeta()], ); const [result, error] = await executeJSONRPCCall(rpcURL, call, { accessToken, }); if (error !== null) { if (error.data instanceof Error) { throw error; } if (error.data.status === 403) { return null; } } if (result === false) { return null; } return accessToken; }; const doSetupRPC = async () => { let storageResult = await HotPocketExtension.api.storage.local.get( ['accessToken', 'baseURL'], ); let accessToken = null; if (storageResult.baseURL) { HotPocketExtension.base_url = storageResult.baseURL; updateRpcURL(); accessToken = await doCheckAuth( storageResult.accessToken || null, ); } if (accessToken === null) { authSessionToken = crypto.randomUUID(); const authTab = await HotPocketExtension.api.tabs.create({ url: HotPocketExtension.api.runtime.getURL( `preauth.html?authSessionToken=${authSessionToken}`, ), }); accessToken = await doHandleAuthFlow(authTab); } return accessToken; }; const doSendTabMessage = (tab, message) => { HotPocketExtension.api.tabs.sendMessage(tab.id, message). then((result) => { HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.doSendTabMessage(): message sent', message, result, ); }). catch((error) => { HotPocketExtension.LOGGER.error( 'HotPocketExtension.background.doSendTabMessage(): could not send message', error, ); }); }; const doUpdateBaseURL = (nextBaseURL) => { HotPocketExtension.base_url = nextBaseURL; updateRpcURL(); HotPocketExtension.api.storage.local. set({ baseURL: nextBaseURL, }). then(() => { HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.doUpdateBaseURL()', 'Base URL saved', ); }). catch((error) => { HotPocketExtension.LOGGER.error( 'HotPocketExtension.background.doUpdateBaseURL()', error, ); }); }; const onTabCreated = (tab) => { HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.onTabCreated()', tab, ); HotPocketExtension.api.action.enable(tab.id).catch((error) => { HotPocketExtension.LOGGER.error( 'HotPocketExtension.background.onTabCreated()', tab.id, error, ); }); }; const onBrowserActionClicked = async (tab) => { HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.onBrowserActionClicked()', tab.url, ); if (!tab.url) { return; } let result = false; let error = null; try { let accessToken = await doSetupRPC(); result = await doSave(accessToken, tab); HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.onBrowserActionClicked()', result, ); } catch (exception) { HotPocketExtension.LOGGER.error( 'Unhandled exception when handling action click', exception, ); error = exception; } const message = { type: 'HotPocket:Extension:save', result: result, error: error, }; doSendTabMessage(tab, message); }; const onMessage = (message, sender, sendResponse) => { HotPocketExtension.LOGGER.debug( 'HotPocketExtension.background.onMessage()', message, sender, sendResponse, ); let response = {ok: true}; try { if (message.type === 'HotPocket:Extension:ping') { doSendTabMessage(sender.tab, { type: 'HotPocket:Extension:pong', }); } else if (message.type === 'HotPocket:Extension:setBaseURL') { doUpdateBaseURL(message.result); } } catch (exception) { HotPocketExtension.LOGGER.error( 'HotPocketExtension.background.onMessage(): Unhandled exception when handling content message', message, exception, ); response.ok = false; } sendResponse(response); return true; }; export default ({...configuration}) => { HotPocketExtension.configure(configuration, { background: true, }); updateRpcURL(); HotPocketExtension.api.tabs.onCreated.addListener(onTabCreated); HotPocketExtension.api.action.onClicked.addListener(onBrowserActionClicked); HotPocketExtension.api.runtime.onMessage.addListener(onMessage); HotPocketExtension.LOGGER.info(`HotPocket v${HotPocketExtension.version} by BTHLabs`); };