BTHLABS-57: Pre-auth page in the extension

This commit is contained in:
Tomek Wójcik 2025-09-16 19:36:15 +00:00
parent 46254730bd
commit 495255206e
9 changed files with 293 additions and 52 deletions

View File

@ -90,3 +90,5 @@ Shared (Extension)/Resources/images/
Shared (Extension)/Resources/background-bundle.js Shared (Extension)/Resources/background-bundle.js
Shared (Extension)/Resources/content-bundle.js Shared (Extension)/Resources/content-bundle.js
Shared (Extension)/Resources/manifest.json Shared (Extension)/Resources/manifest.json
Shared (Extension)/Resources/preauth.html
Shared (Extension)/Resources/preauth.js

View File

@ -114,6 +114,8 @@
"Resources/content-bundle.js", "Resources/content-bundle.js",
Resources/images, Resources/images,
Resources/manifest.json, Resources/manifest.json,
Resources/preauth.html,
Resources/preauth.js,
SafariWebExtensionHandler.m, SafariWebExtensionHandler.m,
); );
target = 4CABCAD42E56F0C900D8A354 /* HotPocket Extension (iOS) */; target = 4CABCAD42E56F0C900D8A354 /* HotPocket Extension (iOS) */;
@ -126,6 +128,8 @@
"Resources/content-bundle.js", "Resources/content-bundle.js",
Resources/images, Resources/images,
Resources/manifest.json, Resources/manifest.json,
Resources/preauth.html,
Resources/preauth.js,
SafariWebExtensionHandler.m, SafariWebExtensionHandler.m,
); );
target = 4CABCADE2E56F0C900D8A354 /* HotPocket Extension (macOS) */; target = 4CABCADE2E56F0C900D8A354 /* HotPocket Extension (macOS) */;

View File

@ -40,7 +40,9 @@ if (BASE_URL === null) {
const PLUGINS = [ const PLUGINS = [
string({ string({
include: /\.html$/, include: [
'src/content/templates/*.html',
],
}), }),
replace({ replace({
include: /\.js$/, include: /\.js$/,
@ -115,6 +117,13 @@ export default [
src: 'assets/images', src: 'assets/images',
dest: OUTPUT_PATH, dest: OUTPUT_PATH,
}, },
{
src: [
'src/content/preauth.html',
'src/content/preauth.js',
],
dest: OUTPUT_PATH,
},
], ],
}), }),
], ],

View File

@ -1,8 +1,23 @@
import HotPocketExtension from '../common'; import HotPocketExtension from '../common';
const AUTH_URL = (new URL('/integrations/extension/authenticate/', HotPocketExtension.base_url)).toString(); const POST_AUTH_PATH = '/integrations/extension/post-authenticate/';
const POST_AUTH_URL = (new URL('/integrations/extension/post-authenticate/', HotPocketExtension.base_url)).toString(); const RPC_PATH = '/rpc/';
const RPC_URL = (new URL('/rpc/', HotPocketExtension.base_url)).toString();
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) => { const makeJSONRPCCall = (method, params) => {
return { return {
@ -43,7 +58,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
}, },
); );
HotPocketExtension.LOGGER.debug( HotPocketExtension.LOGGER.debug(
'HotPocketExtension.content.executeJSONRPCCall()', response, 'HotPocketExtension.background.executeJSONRPCCall()', response,
); );
if (response.status !== 200) { if (response.status !== 200) {
@ -56,7 +71,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
const callResult = await response.json(); const callResult = await response.json();
if (callResult.error) { if (callResult.error) {
HotPocketExtension.LOGGER.error( HotPocketExtension.LOGGER.error(
'HotPocketExtension.content.executeJSONRPCCall(): RPC error', 'HotPocketExtension.background.executeJSONRPCCall(): RPC error',
callResult.error.code, callResult.error.code,
callResult.error.message, callResult.error.message,
callResult.error.data, callResult.error.data,
@ -69,7 +84,7 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
} }
} catch (exception) { } catch (exception) {
HotPocketExtension.LOGGER.error( HotPocketExtension.LOGGER.error(
'HotPocketExtension.content.executeJSONRPCCall(): Fetch error', exception, 'HotPocketExtension.background.executeJSONRPCCall(): Fetch error', exception,
); );
error = { error = {
code: -32000, code: -32000,
@ -90,9 +105,9 @@ const getAccessTokenMeta = () => {
const doSave = async (accessToken, tab) => { const doSave = async (accessToken, tab) => {
const call = makeJSONRPCCall('saves.create', [tab.url]); 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.LOGGER.debug(
'HotPocketExtension.content.doSave():', result, error, 'HotPocketExtension.background.doSave():', result, error,
); );
if (error !== null) { if (error !== null) {
@ -112,7 +127,7 @@ const doCreateAndStoreAccessToken = async (authKey) => {
); );
const [accessToken, error] = await executeJSONRPCCall( const [accessToken, error] = await executeJSONRPCCall(
RPC_URL, accessTokenCall, {accessToken: null}, rpcURL, accessTokenCall, {accessToken: null},
); );
if (error === null) { if (error === null) {
@ -125,15 +140,34 @@ const doCreateAndStoreAccessToken = async (authKey) => {
}; };
const doHandleAuthFlow = (authTab) => { const doHandleAuthFlow = (authTab) => {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.doHandleAuthFlow()', authTab,
);
let currentAuthTabId = authTab.id;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const onTabsUpdated = (tabId, changeInfo, updatedTab) => { const onTabsUpdated = (tabId, changeInfo, updatedTab) => {
if (tabId === authTab.id) { const changedURL = changeInfo.url;
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.content.doHandleAuthFlow.onTabsUpdated()', updatedTab, changeInfo,
);
const changedURL = changeInfo.url; HotPocketExtension.LOGGER.debug(
if (changedURL && changedURL.startsWith(POST_AUTH_URL)) { '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 parsedChangedURL = new URL(changedURL);
const authKey = parsedChangedURL.searchParams.get('auth_key'); const authKey = parsedChangedURL.searchParams.get('auth_key');
@ -154,8 +188,9 @@ const doHandleAuthFlow = (authTab) => {
reject(error); reject(error);
}). }).
finally(() => { finally(() => {
authSessionToken = null;
HotPocketExtension.api.tabs.onUpdated.removeListener(onTabsUpdated); 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()], [accessToken, getAccessTokenMeta()],
); );
const [result, error] = await executeJSONRPCCall(RPC_URL, call, { const [result, error] = await executeJSONRPCCall(rpcURL, call, {
accessToken, accessToken,
}); });
@ -196,15 +231,28 @@ const doCheckAuth = async (accessToken) => {
return accessToken; return accessToken;
}; };
const doGetAccessToken = async () => { const doSetupRPC = async () => {
let storageResult = await HotPocketExtension.api.storage.local.get('accessToken'); let storageResult = await HotPocketExtension.api.storage.local.get(
let accessToken = await doCheckAuth( ['accessToken', 'baseURL'],
storageResult.accessToken || null,
); );
let accessToken = null;
if (storageResult.baseURL) {
HotPocketExtension.base_url = storageResult.baseURL;
updateRpcURL();
accessToken = await doCheckAuth(
storageResult.accessToken || null,
);
}
if (accessToken === null) { if (accessToken === null) {
authSessionToken = crypto.randomUUID();
const authTab = await HotPocketExtension.api.tabs.create({ const authTab = await HotPocketExtension.api.tabs.create({
url: AUTH_URL, url: HotPocketExtension.api.runtime.getURL(
`preauth.html?authSessionToken=${authSessionToken}`,
),
}); });
accessToken = await doHandleAuthFlow(authTab); accessToken = await doHandleAuthFlow(authTab);
@ -217,24 +265,53 @@ const doSendTabMessage = (tab, message) => {
HotPocketExtension.api.tabs.sendMessage(tab.id, message). HotPocketExtension.api.tabs.sendMessage(tab.id, message).
then((result) => { then((result) => {
HotPocketExtension.LOGGER.debug( HotPocketExtension.LOGGER.debug(
'HotPocketExtension.content.doSendTabMessage(): message sent', message, result, 'HotPocketExtension.background.doSendTabMessage(): message sent',
message,
result,
); );
}). }).
catch((error) => { catch((error) => {
HotPocketExtension.LOGGER.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) => { const onTabCreated = (tab) => {
HotPocketExtension.LOGGER.debug('HotPocketExtension.onTabCreated()', tab); HotPocketExtension.LOGGER.debug(
HotPocketExtension.api.action.enable(tab.id); '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) => { const onBrowserActionClicked = async (tab) => {
HotPocketExtension.LOGGER.debug( HotPocketExtension.LOGGER.debug(
'HotPocketExtension.onBrowserActionClicked()', tab.url, 'HotPocketExtension.background.onBrowserActionClicked()', tab.url,
); );
if (!tab.url) { if (!tab.url) {
@ -245,11 +322,11 @@ const onBrowserActionClicked = async (tab) => {
let error = null; let error = null;
try { try {
let accessToken = await doGetAccessToken(); let accessToken = await doSetupRPC();
result = await doSave(accessToken, tab); result = await doSave(accessToken, tab);
HotPocketExtension.LOGGER.debug( HotPocketExtension.LOGGER.debug(
'HotPocketExtension.onBrowserActionClicked()', result, 'HotPocketExtension.background.onBrowserActionClicked()', result,
); );
} catch (exception) { } catch (exception) {
HotPocketExtension.LOGGER.error( HotPocketExtension.LOGGER.error(
@ -276,10 +353,11 @@ const onMessage = (message, sender, sendResponse) => {
let response = {ok: true}; let response = {ok: true};
try { try {
if (message.type === 'HotPocket:Extension:ping') { if (message.type === 'HotPocket:Extension:ping') {
HotPocketExtension.LOGGER.debug(sender.tab.id);
doSendTabMessage(sender.tab, { doSendTabMessage(sender.tab, {
type: 'HotPocket:Extension:pong', type: 'HotPocket:Extension:pong',
}); });
} else if (message.type === 'HotPocket:Extension:setBaseURL') {
doUpdateBaseURL(message.result);
} }
} catch (exception) { } catch (exception) {
HotPocketExtension.LOGGER.error( HotPocketExtension.LOGGER.error(
@ -300,6 +378,8 @@ export default ({...configuration}) => {
background: true, background: true,
}); });
updateRpcURL();
HotPocketExtension.api.tabs.onCreated.addListener(onTabCreated); HotPocketExtension.api.tabs.onCreated.addListener(onTabCreated);
HotPocketExtension.api.action.onClicked.addListener(onBrowserActionClicked); HotPocketExtension.api.action.onClicked.addListener(onBrowserActionClicked);

View File

@ -8,7 +8,7 @@ const HotPocketExtension = {
version: __HOTPOCKET_EXTENSION_VERSION__, version: __HOTPOCKET_EXTENSION_VERSION__,
debug: DEBUG, debug: DEBUG,
api: null, api: null,
base_url: __HOTPOCKET_EXTENSION_BASE_URL__, base_url: null,
LOGGER: { LOGGER: {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
debug: (DEBUG === true) ? console.log : noop, debug: (DEBUG === true) ? console.log : noop,

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,45 @@
/*!
* HotPocket by BTHLabs (https://hotpocket.app/)
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (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;
});
});
})();

View File

@ -19,7 +19,7 @@
"data_collection_permissions": { "data_collection_permissions": {
"required": [ "required": [
"websiteActivity", "websiteActivity",
"technicalAndInteraction" "browsingActivity"
] ]
} }
} }

View File

@ -1,9 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import base64
import datetime import datetime
from invoke import task from invoke import Context, task
from hotpocket_workspace_tools import get_workspace_mode, WorkspaceMode from hotpocket_workspace_tools import get_workspace_mode, WorkspaceMode
@ -63,7 +64,7 @@ WORKSPACE_MODE = get_workspace_mode()
ALL_SERVICES = ['backend', 'packages', 'extension'] 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: match WORKSPACE_MODE:
case WorkspaceMode.DOCKER: case WorkspaceMode.DOCKER:
return ctx.run( return ctx.run(
@ -75,7 +76,7 @@ def _run_in_service(ctx, service, command, **kwargs):
ctx.run(f'direnv exec . {command}') ctx.run(f'direnv exec . {command}')
def _get_version(ctx, service): def _get_version(ctx: Context, service):
with ctx.cd(f'services/{service}'): with ctx.cd(f'services/{service}'):
run_result = ctx.run('poetry version -s', hide='out') run_result = ctx.run('poetry version -s', hide='out')
@ -88,27 +89,27 @@ def _get_head_sha(ctx):
@task @task
def clean(ctx, service): def clean(ctx: Context, service):
_run_in_service(ctx, service, 'inv clean') _run_in_service(ctx, service, 'inv clean')
@task @task
def test(ctx, service): def test(ctx: Context, service):
_run_in_service(ctx, service, 'inv test') _run_in_service(ctx, service, 'inv test')
@task @task
def lint(ctx, service): def lint(ctx: Context, service):
_run_in_service(ctx, service, 'inv lint') _run_in_service(ctx, service, 'inv lint')
@task @task
def typecheck(ctx, service): def typecheck(ctx: Context, service):
_run_in_service(ctx, service, 'inv typecheck') _run_in_service(ctx, service, 'inv typecheck')
@task @task
def shell(ctx, service): def shell(ctx: Context, service):
assert WORKSPACE_MODE == WorkspaceMode.DOCKER, ( assert WORKSPACE_MODE == WorkspaceMode.DOCKER, (
'Just `cd services/{service}` ;)' 'Just `cd services/{service}` ;)'
) )
@ -116,12 +117,21 @@ def shell(ctx, service):
@task @task
def django_shell(ctx, service): def django_shell(ctx: Context, service):
_run_in_service(ctx, service, 'inv django-shell') _run_in_service(ctx, service, 'inv django-shell')
@task @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, service,
context=None, context=None,
builder=None, builder=None,
@ -175,7 +185,7 @@ def build(ctx,
@task @task
def publish(ctx, def publish(ctx: Context,
service, service,
context=None, context=None,
target='deployment', target='deployment',
@ -199,12 +209,12 @@ def publish(ctx,
@task @task
def ci(ctx, service): def ci(ctx: Context, service):
_run_in_service(ctx, service, 'inv ci') _run_in_service(ctx, service, 'inv ci')
@task @task
def setup(ctx, service=None): def setup(ctx: Context, service=None):
services_to_setup = [] services_to_setup = []
services_to_setup = [*ALL_SERVICES] services_to_setup = [*ALL_SERVICES]
@ -216,7 +226,7 @@ def setup(ctx, service=None):
@task @task
def install(ctx, service=None): def install(ctx: Context, service=None):
services_to_setup = [] services_to_setup = []
services_to_setup = [*ALL_SERVICES] services_to_setup = [*ALL_SERVICES]
@ -228,7 +238,7 @@ def install(ctx, service=None):
@task @task
def lock(ctx, service): def lock(ctx: Context, service):
_run_in_service(ctx, service, 'poetry lock --no-update') _run_in_service(ctx, service, 'poetry lock --no-update')
@ -238,20 +248,20 @@ def start_cloud(ctx):
@task @task
def start_web(ctx, service): def start_web(ctx: Context, service):
_run_in_service(ctx, service, 'inv start-web') _run_in_service(ctx, service, 'inv start-web')
@task @task
def start_celery_worker(ctx, service): def start_celery_worker(ctx: Context, service):
_run_in_service(ctx, service, 'inv start-worker') _run_in_service(ctx, service, 'inv start-worker')
@task @task
def start_celery_beat(ctx, service): def start_celery_beat(ctx: Context, service):
_run_in_service(ctx, service, 'inv start-beat') _run_in_service(ctx, service, 'inv start-beat')
@task @task
def start_app(ctx, service, app): def start_app(ctx: Context, service, app):
_run_in_service(ctx, service, f'inv start-{app}') _run_in_service(ctx, service, f'inv start-{app}')