diff --git a/services/apple/.gitignore b/services/apple/.gitignore index 911edee..a09cd82 100644 --- a/services/apple/.gitignore +++ b/services/apple/.gitignore @@ -90,3 +90,5 @@ Shared (Extension)/Resources/images/ Shared (Extension)/Resources/background-bundle.js Shared (Extension)/Resources/content-bundle.js Shared (Extension)/Resources/manifest.json +Shared (Extension)/Resources/preauth.html +Shared (Extension)/Resources/preauth.js diff --git a/services/apple/HotPocket.xcodeproj/project.pbxproj b/services/apple/HotPocket.xcodeproj/project.pbxproj index cd9d48b..ff99e18 100644 --- a/services/apple/HotPocket.xcodeproj/project.pbxproj +++ b/services/apple/HotPocket.xcodeproj/project.pbxproj @@ -114,6 +114,8 @@ "Resources/content-bundle.js", Resources/images, Resources/manifest.json, + Resources/preauth.html, + Resources/preauth.js, SafariWebExtensionHandler.m, ); target = 4CABCAD42E56F0C900D8A354 /* HotPocket Extension (iOS) */; @@ -126,6 +128,8 @@ "Resources/content-bundle.js", Resources/images, Resources/manifest.json, + Resources/preauth.html, + Resources/preauth.js, SafariWebExtensionHandler.m, ); target = 4CABCADE2E56F0C900D8A354 /* HotPocket Extension (macOS) */; diff --git a/services/extension/rollup.config.js b/services/extension/rollup.config.js index 95ea0b3..628cddd 100644 --- a/services/extension/rollup.config.js +++ b/services/extension/rollup.config.js @@ -40,7 +40,9 @@ if (BASE_URL === null) { const PLUGINS = [ string({ - include: /\.html$/, + include: [ + 'src/content/templates/*.html', + ], }), replace({ include: /\.js$/, @@ -115,6 +117,13 @@ export default [ src: 'assets/images', dest: OUTPUT_PATH, }, + { + src: [ + 'src/content/preauth.html', + 'src/content/preauth.js', + ], + dest: OUTPUT_PATH, + }, ], }), ], diff --git a/services/extension/src/background/main.js b/services/extension/src/background/main.js index 9397bea..e27ccb6 100644 --- a/services/extension/src/background/main.js +++ b/services/extension/src/background/main.js @@ -1,8 +1,23 @@ import HotPocketExtension from '../common'; -const AUTH_URL = (new URL('/integrations/extension/authenticate/', HotPocketExtension.base_url)).toString(); -const POST_AUTH_URL = (new URL('/integrations/extension/post-authenticate/', HotPocketExtension.base_url)).toString(); -const RPC_URL = (new URL('/rpc/', HotPocketExtension.base_url)).toString(); +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 { @@ -43,7 +58,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => { }, ); HotPocketExtension.LOGGER.debug( - 'HotPocketExtension.content.executeJSONRPCCall()', response, + 'HotPocketExtension.background.executeJSONRPCCall()', response, ); if (response.status !== 200) { @@ -56,7 +71,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => { const callResult = await response.json(); if (callResult.error) { HotPocketExtension.LOGGER.error( - 'HotPocketExtension.content.executeJSONRPCCall(): RPC error', + 'HotPocketExtension.background.executeJSONRPCCall(): RPC error', callResult.error.code, callResult.error.message, callResult.error.data, @@ -69,7 +84,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => { } } catch (exception) { HotPocketExtension.LOGGER.error( - 'HotPocketExtension.content.executeJSONRPCCall(): Fetch error', exception, + 'HotPocketExtension.background.executeJSONRPCCall(): Fetch error', exception, ); error = { code: -32000, @@ -90,9 +105,9 @@ const getAccessTokenMeta = () => { const doSave = async (accessToken, tab) => { const call = makeJSONRPCCall('saves.create', [tab.url]); - const [result, error] = await executeJSONRPCCall(RPC_URL, call, {accessToken}); + const [result, error] = await executeJSONRPCCall(rpcURL, call, {accessToken}); HotPocketExtension.LOGGER.debug( - 'HotPocketExtension.content.doSave():', result, error, + 'HotPocketExtension.background.doSave():', result, error, ); if (error !== null) { @@ -112,7 +127,7 @@ const doCreateAndStoreAccessToken = async (authKey) => { ); const [accessToken, error] = await executeJSONRPCCall( - RPC_URL, accessTokenCall, {accessToken: null}, + rpcURL, accessTokenCall, {accessToken: null}, ); if (error === null) { @@ -125,15 +140,34 @@ const doCreateAndStoreAccessToken = async (authKey) => { }; const doHandleAuthFlow = (authTab) => { + HotPocketExtension.LOGGER.debug( + 'HotPocketExtension.background.doHandleAuthFlow()', authTab, + ); + let currentAuthTabId = authTab.id; + return new Promise((resolve, reject) => { const onTabsUpdated = (tabId, changeInfo, updatedTab) => { - if (tabId === authTab.id) { - HotPocketExtension.LOGGER.debug( - 'HotPocketExtension.content.doHandleAuthFlow.onTabsUpdated()', updatedTab, changeInfo, - ); + const changedURL = changeInfo.url; - const changedURL = changeInfo.url; - if (changedURL && changedURL.startsWith(POST_AUTH_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'); @@ -154,8 +188,9 @@ const doHandleAuthFlow = (authTab) => { reject(error); }). finally(() => { + authSessionToken = null; HotPocketExtension.api.tabs.onUpdated.removeListener(onTabsUpdated); - HotPocketExtension.api.tabs.remove(authTab.id); + HotPocketExtension.api.tabs.remove(currentAuthTabId); }); } } @@ -175,7 +210,7 @@ const doCheckAuth = async (accessToken) => { [accessToken, getAccessTokenMeta()], ); - const [result, error] = await executeJSONRPCCall(RPC_URL, call, { + const [result, error] = await executeJSONRPCCall(rpcURL, call, { accessToken, }); @@ -196,15 +231,28 @@ const doCheckAuth = async (accessToken) => { return accessToken; }; -const doGetAccessToken = async () => { - let storageResult = await HotPocketExtension.api.storage.local.get('accessToken'); - let accessToken = await doCheckAuth( - storageResult.accessToken || null, +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: AUTH_URL, + url: HotPocketExtension.api.runtime.getURL( + `preauth.html?authSessionToken=${authSessionToken}`, + ), }); accessToken = await doHandleAuthFlow(authTab); @@ -217,24 +265,53 @@ const doSendTabMessage = (tab, message) => { HotPocketExtension.api.tabs.sendMessage(tab.id, message). then((result) => { HotPocketExtension.LOGGER.debug( - 'HotPocketExtension.content.doSendTabMessage(): message sent', message, result, + 'HotPocketExtension.background.doSendTabMessage(): message sent', + message, + result, ); }). catch((error) => { HotPocketExtension.LOGGER.error( - 'HotPocketExtension.content.doSendTabMessage(): could not send message', 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.onTabCreated()', tab); - HotPocketExtension.api.action.enable(tab.id); + 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.onBrowserActionClicked()', tab.url, + 'HotPocketExtension.background.onBrowserActionClicked()', tab.url, ); if (!tab.url) { @@ -245,11 +322,11 @@ const onBrowserActionClicked = async (tab) => { let error = null; try { - let accessToken = await doGetAccessToken(); + let accessToken = await doSetupRPC(); result = await doSave(accessToken, tab); HotPocketExtension.LOGGER.debug( - 'HotPocketExtension.onBrowserActionClicked()', result, + 'HotPocketExtension.background.onBrowserActionClicked()', result, ); } catch (exception) { HotPocketExtension.LOGGER.error( @@ -276,10 +353,11 @@ const onMessage = (message, sender, sendResponse) => { let response = {ok: true}; try { if (message.type === 'HotPocket:Extension:ping') { - HotPocketExtension.LOGGER.debug(sender.tab.id); doSendTabMessage(sender.tab, { type: 'HotPocket:Extension:pong', }); + } else if (message.type === 'HotPocket:Extension:setBaseURL') { + doUpdateBaseURL(message.result); } } catch (exception) { HotPocketExtension.LOGGER.error( @@ -300,6 +378,8 @@ export default ({...configuration}) => { background: true, }); + updateRpcURL(); + HotPocketExtension.api.tabs.onCreated.addListener(onTabCreated); HotPocketExtension.api.action.onClicked.addListener(onBrowserActionClicked); diff --git a/services/extension/src/common.js b/services/extension/src/common.js index e029f2a..4be8ab4 100644 --- a/services/extension/src/common.js +++ b/services/extension/src/common.js @@ -8,7 +8,7 @@ const HotPocketExtension = { version: __HOTPOCKET_EXTENSION_VERSION__, debug: DEBUG, api: null, - base_url: __HOTPOCKET_EXTENSION_BASE_URL__, + base_url: null, LOGGER: { // eslint-disable-next-line no-console debug: (DEBUG === true) ? console.log : noop, diff --git a/services/extension/src/content/preauth.html b/services/extension/src/content/preauth.html new file mode 100644 index 0000000..dbde3b2 --- /dev/null +++ b/services/extension/src/content/preauth.html @@ -0,0 +1,91 @@ + + + + + + + + + HotPocket by BTHLabs + + + + + + +
+
+
+
+
+

HotPocket by BTHLabs

+
+
+
+
+ + +
+ Enter the URL to your HotPocket instance, e.g. https://my.hotpocket.app/. +
+
+
+ +
+
+
+
+

+ + HotPocket by BTHLabs v25.9.12 + +
+ Copyright © 2025-present by BTHLabs. All rights reserved. +

+
+
+
+ + + diff --git a/services/extension/src/content/preauth.js b/services/extension/src/content/preauth.js new file mode 100644 index 0000000..68312d7 --- /dev/null +++ b/services/extension/src/content/preauth.js @@ -0,0 +1,45 @@ +/*! + * HotPocket by BTHLabs (https://hotpocket.app/) + * Copyright 2025-present BTHLabs (https://bthlabs.pl/) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +(() => { + 'use strict'; + + document.addEventListener('DOMContentLoaded', (event) => { + const form = document.getElementById('PreauthForm'); + form.addEventListener('submit', (event) => { + event.stopPropagation(); + event.preventDefault(); + + const inputBaseURL = document.getElementById('id_base_url'); + const baseURL = inputBaseURL.value; + + let api = window.browser || null; + if (api === null && window.chrome) { + api = window.chrome; + } + + api.runtime.sendMessage({ + type: 'HotPocket:Extension:setBaseURL', + result: baseURL, + }); + + const loginURL = new URL('/integrations/extension/authenticate/', inputBaseURL.value); + window.location.replace(loginURL.toString()); + + return false; + }); + }); +})(); diff --git a/services/extension/src/manifest/firefox.json b/services/extension/src/manifest/firefox.json index ef916fe..733b9fa 100644 --- a/services/extension/src/manifest/firefox.json +++ b/services/extension/src/manifest/firefox.json @@ -19,7 +19,7 @@ "data_collection_permissions": { "required": [ "websiteActivity", - "technicalAndInteraction" + "browsingActivity" ] } } diff --git a/tasks.py b/tasks.py index 46b75f5..43cdfe1 100644 --- a/tasks.py +++ b/tasks.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import base64 import datetime -from invoke import task +from invoke import Context, task from hotpocket_workspace_tools import get_workspace_mode, WorkspaceMode @@ -63,7 +64,7 @@ WORKSPACE_MODE = get_workspace_mode() ALL_SERVICES = ['backend', 'packages', 'extension'] -def _run_in_service(ctx, service, command, **kwargs): +def _run_in_service(ctx: Context, service, command, **kwargs): match WORKSPACE_MODE: case WorkspaceMode.DOCKER: return ctx.run( @@ -75,7 +76,7 @@ def _run_in_service(ctx, service, command, **kwargs): ctx.run(f'direnv exec . {command}') -def _get_version(ctx, service): +def _get_version(ctx: Context, service): with ctx.cd(f'services/{service}'): run_result = ctx.run('poetry version -s', hide='out') @@ -88,27 +89,27 @@ def _get_head_sha(ctx): @task -def clean(ctx, service): +def clean(ctx: Context, service): _run_in_service(ctx, service, 'inv clean') @task -def test(ctx, service): +def test(ctx: Context, service): _run_in_service(ctx, service, 'inv test') @task -def lint(ctx, service): +def lint(ctx: Context, service): _run_in_service(ctx, service, 'inv lint') @task -def typecheck(ctx, service): +def typecheck(ctx: Context, service): _run_in_service(ctx, service, 'inv typecheck') @task -def shell(ctx, service): +def shell(ctx: Context, service): assert WORKSPACE_MODE == WorkspaceMode.DOCKER, ( 'Just `cd services/{service}` ;)' ) @@ -116,12 +117,21 @@ def shell(ctx, service): @task -def django_shell(ctx, service): +def django_shell(ctx: Context, service): _run_in_service(ctx, service, 'inv django-shell') @task -def build(ctx, +def png_to_data_url(ctx: Context, png_path): + with open(png_path, 'rb') as png_f: + data = png_f.read() + + encoded_data = base64.b64encode(data) + print(f'data:image/png;base64,{encoded_data.decode("utf-8")}') + + +@task +def build(ctx: Context, service, context=None, builder=None, @@ -175,7 +185,7 @@ def build(ctx, @task -def publish(ctx, +def publish(ctx: Context, service, context=None, target='deployment', @@ -199,12 +209,12 @@ def publish(ctx, @task -def ci(ctx, service): +def ci(ctx: Context, service): _run_in_service(ctx, service, 'inv ci') @task -def setup(ctx, service=None): +def setup(ctx: Context, service=None): services_to_setup = [] services_to_setup = [*ALL_SERVICES] @@ -216,7 +226,7 @@ def setup(ctx, service=None): @task -def install(ctx, service=None): +def install(ctx: Context, service=None): services_to_setup = [] services_to_setup = [*ALL_SERVICES] @@ -228,7 +238,7 @@ def install(ctx, service=None): @task -def lock(ctx, service): +def lock(ctx: Context, service): _run_in_service(ctx, service, 'poetry lock --no-update') @@ -238,20 +248,20 @@ def start_cloud(ctx): @task -def start_web(ctx, service): +def start_web(ctx: Context, service): _run_in_service(ctx, service, 'inv start-web') @task -def start_celery_worker(ctx, service): +def start_celery_worker(ctx: Context, service): _run_in_service(ctx, service, 'inv start-worker') @task -def start_celery_beat(ctx, service): +def start_celery_beat(ctx: Context, service): _run_in_service(ctx, service, 'inv start-beat') @task -def start_app(ctx, service, app): +def start_app(ctx: Context, service, app): _run_in_service(ctx, service, f'inv start-{app}')