From bc7b86a242673d7831f6105d000995d9f4d63e09 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sun, 19 Nov 2023 14:50:46 -0500 Subject: hasty not working update to get my workspace clean --- extension/src/assets/inputs.scss | 2 +- extension/src/bg-api/bg-api.ts | 145 -------------- extension/src/bg-api/content-script.ts | 48 ----- extension/src/bg-api/options.ts | 80 -------- extension/src/bg-api/popup.ts | 66 ------- extension/src/bg-api/types.ts | 50 ----- extension/src/entries/background/auth-api.ts | 161 ---------------- extension/src/entries/background/history.ts | 82 -------- extension/src/entries/background/identity-api.ts | 59 ------ extension/src/entries/background/main.ts | 119 +++--------- extension/src/entries/background/nostr-api.ts | 110 ----------- extension/src/entries/background/permissions.ts | 80 -------- .../src/entries/background/server-api/endpoints.ts | 72 ------- .../src/entries/background/server-api/index.ts | 84 -------- extension/src/entries/background/settings.ts | 125 ------------ extension/src/entries/background/types.ts | 76 -------- extension/src/entries/contentScript/nostr-shim.js | 73 ++++--- .../primary/components/PromptPopup.vue | 19 +- .../src/entries/contentScript/primary/main.js | 17 ++ extension/src/entries/options/App.vue | 48 ++--- .../src/entries/options/components/Identities.vue | 56 +++--- .../src/entries/options/components/Privacy.vue | 61 +++++- .../entries/options/components/SiteSettings.vue | 118 +++++------- extension/src/entries/options/main.js | 17 +- extension/src/entries/popup/App.vue | 2 +- .../entries/popup/Components/IdentitySelection.vue | 20 +- extension/src/entries/popup/Components/Login.vue | 16 +- .../src/entries/popup/Components/PageContent.vue | 113 ++++++----- extension/src/entries/popup/local.scss | 18 ++ extension/src/entries/popup/main.js | 15 +- extension/src/entries/store/allowedOrigins.ts | 43 +++++ extension/src/entries/store/features.ts | 116 +++++++++++ extension/src/entries/store/identity.ts | 43 +++++ extension/src/entries/store/index.ts | 60 ++++++ extension/src/entries/store/types.ts | 9 + extension/src/features/account-api.ts | 190 ++++++++++++++++++ extension/src/features/auth-api.ts | 123 ++++++++++++ extension/src/features/framework/index.ts | 214 +++++++++++++++++++++ extension/src/features/history.ts | 42 ++++ extension/src/features/identity-api.ts | 112 +++++++++++ extension/src/features/index.ts | 33 ++++ extension/src/features/nip07allow-api.ts | 181 +++++++++++++++++ extension/src/features/nostr-api.ts | 79 ++++++++ extension/src/features/permissions.ts | 80 ++++++++ extension/src/features/server-api/endpoints.ts | 70 +++++++ extension/src/features/server-api/index.ts | 137 +++++++++++++ extension/src/features/settings.ts | 165 ++++++++++++++++ extension/src/features/tagfilter-api.ts | 125 ++++++++++++ extension/src/features/types.ts | 147 ++++++++++++++ extension/src/manifest.js | 5 +- 50 files changed, 2362 insertions(+), 1564 deletions(-) delete mode 100644 extension/src/bg-api/bg-api.ts delete mode 100644 extension/src/bg-api/content-script.ts delete mode 100644 extension/src/bg-api/options.ts delete mode 100644 extension/src/bg-api/popup.ts delete mode 100644 extension/src/bg-api/types.ts delete mode 100644 extension/src/entries/background/auth-api.ts delete mode 100644 extension/src/entries/background/history.ts delete mode 100644 extension/src/entries/background/identity-api.ts delete mode 100644 extension/src/entries/background/nostr-api.ts delete mode 100644 extension/src/entries/background/permissions.ts delete mode 100644 extension/src/entries/background/server-api/endpoints.ts delete mode 100644 extension/src/entries/background/server-api/index.ts delete mode 100644 extension/src/entries/background/settings.ts delete mode 100644 extension/src/entries/background/types.ts create mode 100644 extension/src/entries/popup/local.scss create mode 100644 extension/src/entries/store/allowedOrigins.ts create mode 100644 extension/src/entries/store/features.ts create mode 100644 extension/src/entries/store/identity.ts create mode 100644 extension/src/entries/store/index.ts create mode 100644 extension/src/entries/store/types.ts create mode 100644 extension/src/features/account-api.ts create mode 100644 extension/src/features/auth-api.ts create mode 100644 extension/src/features/framework/index.ts create mode 100644 extension/src/features/history.ts create mode 100644 extension/src/features/identity-api.ts create mode 100644 extension/src/features/index.ts create mode 100644 extension/src/features/nip07allow-api.ts create mode 100644 extension/src/features/nostr-api.ts create mode 100644 extension/src/features/permissions.ts create mode 100644 extension/src/features/server-api/endpoints.ts create mode 100644 extension/src/features/server-api/index.ts create mode 100644 extension/src/features/settings.ts create mode 100644 extension/src/features/tagfilter-api.ts create mode 100644 extension/src/features/types.ts (limited to 'extension/src') diff --git a/extension/src/assets/inputs.scss b/extension/src/assets/inputs.scss index 62d03d4..05c8d33 100644 --- a/extension/src/assets/inputs.scss +++ b/extension/src/assets/inputs.scss @@ -1,7 +1,7 @@ input.input, select.input, textarea.input { - @apply duration-100 ease-in-out outline-none border px-2 py-1.5; + @apply duration-100 ease-in-out outline-none border px-1.5 py-1; @apply border-gray-200 bg-inherit dark:border-dark-400 dark:text-white hover:border-gray-300 hover:dark:border-dark-200; } diff --git a/extension/src/bg-api/bg-api.ts b/extension/src/bg-api/bg-api.ts deleted file mode 100644 index 86e6b68..0000000 --- a/extension/src/bg-api/bg-api.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - - -import { assign, isEqual, orderBy, transform } from 'lodash' -import { apiCall, debugLog } from "@vnuge/vnlib.browser" -import { reactive, toRefs } from "vue" -import { useWindowFocus } from '@vueuse/core' -import { NostrPubKey } from '../entries/background/types' -import { ClientStatus, NostrIdentiy, SendMessageHandler, UseStatusResult, PluginConfig } from './types' - - -//Hold local status -const status = reactive({ - loggedIn: false, - userName: '', - selectedKey: undefined, - darkMode: true -}) - -const focused = useWindowFocus() - -const updateStatusAsync = async (sendMessage: SendMessageHandler) => { - - //Get the status from the background script - const result = await sendMessage('getStatus', {}, 'background') - - const ls = status as any; - const res = result as any; - - //Check if the status has changed - for (const key in result) { - if (!isEqual(ls[key], res)){ - //Update the status and break - assign(status, result) - break; - } - } - - //Get the selected publicKey - const selected = await sendMessage('getPublicKey', {}, 'background') - - if(!isEqual(status.selectedKey, selected)){ - debugLog('Selected key changed') - assign(status, { selectedKey: selected }) - } -} - -/** - * Keeps a reactive status object that up to date with the background script - * @returns {Readonly>} - */ -export const useStatus = (sendMessage: SendMessageHandler, bypassFocus : boolean): UseStatusResult => { - //Configure timer get status from the background, only when the window is focused - setInterval(() => (bypassFocus || focused.value) ? updateStatusAsync(sendMessage) : null, 200); - - //return a refs object - return { - toRefs: () => toRefs(status), - update: () => updateStatusAsync(sendMessage) - } -} - -export const useManagment = (sendMessage: SendMessageHandler) =>{ - - - const getProfile = async () => { - //Send the login request to the background script - return await apiCall(async () => await sendMessage('getProfile', {}, 'background')) - } - - const getAllKeys = async (): Promise => { - //Send the login request to the background script - const keys = (await apiCall(async () => await sendMessage('getAllKeys', {}, 'background')) ?? []) as NostrPubKey[] - - const formattedKeys = transform(keys, (result, key) => { - result.push({ - ...key, - Created: new Date(key.Created).toLocaleString(), - LastModified: new Date(key.LastModified).toLocaleString() - }) - }, [] as NostrPubKey[]) - - return orderBy(formattedKeys, 'Created', 'desc') - } - - const selectKey = async (key: NostrPubKey) => { - await apiCall(async () => { - //Send the login request to the background script - await sendMessage('selectKey', { ...key }, 'background') - }) - //Update the status after the key is selected - updateStatusAsync(sendMessage) - } - - const createIdentity = async (identity: NostrIdentiy) => { - await apiCall(async ({toaster}) => { - //Send the login request to the background script - await sendMessage('createIdentity', { ...identity }, 'background') - toaster.form.success({ - title: 'Success', - text: 'Identity created successfully' - }) - }) - } - - const updateIdentity = async (identity: NostrIdentiy) => { - await apiCall(async ({toaster}) => { - //Send the login request to the background script - await sendMessage('updateIdentity', { ...identity }, 'background') - toaster.form.success({ - title: 'Success', - text: 'Identity updated successfully' - }) - }) - } - - const getSiteConfig = async (): Promise => { - return await apiCall(async () => { - //Send the login request to the background script - return await sendMessage('getSiteConfig', {}, 'background') - }) - } - - return { - getProfile, - getAllKeys, - selectKey, - createIdentity, - updateIdentity, - getSiteConfig - } -} \ No newline at end of file diff --git a/extension/src/bg-api/content-script.ts b/extension/src/bg-api/content-script.ts deleted file mode 100644 index 7b64e81..0000000 --- a/extension/src/bg-api/content-script.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - - -import { apiCall } from "@vnuge/vnlib.browser"; -import { useManagment as _mgmt, useStatus as _sts } from "./bg-api" -import { sendMessage } from "webext-bridge/content-script" - -export const useStatus = (() => { - const status = _sts(sendMessage, false); - - return () => { - const refs = status.toRefs(); - //run status when called and dont await - status.update(); - return refs; - } -})() - -export const useManagment = (() => { - const mgmt = _mgmt(sendMessage); - - const isEnabledSite = async () => { - await apiCall(async ({ toaster }) => { - - //Send the login request to the background script - const data = await sendMessage('isSiteEnabled', { }, 'background') - }) - } - - return () => { - return { - ...mgmt, - } - } -})() diff --git a/extension/src/bg-api/options.ts b/extension/src/bg-api/options.ts deleted file mode 100644 index 4313f35..0000000 --- a/extension/src/bg-api/options.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - - -import { apiCall } from "@vnuge/vnlib.browser"; -import { useManagment as _mgmt, useStatus as _sts } from "./bg-api" -import { sendMessage } from "webext-bridge/options" -import { truncate } from "lodash"; -import { NostrIdentiy, PluginConfig } from "./types"; - -enum HistoryType { - get = 'get', - clear = 'clear', - remove = 'remove', - push = 'push' -} - -interface HistoryMessage{ - readonly action: string, - readonly event?: any -} - -export const useManagment = (() => { - const mgmt = _mgmt(sendMessage); - - const saveSiteConfig = async (config: PluginConfig) => { - await apiCall(async ({ toaster }) => { - //Send the login request to the background script - await sendMessage('setSiteConfig', { ...config }, 'background') - - toaster.form.info({ - title: 'Saved', - text: 'Site config saved' - }) - }) - } - - const deleteIdentity = async (key: NostrIdentiy) => { - await apiCall(async ({ toaster }) => { - //Delete the desired key async, if it fails it will throw - await sendMessage('deleteKey', { ...key }, 'background') - - toaster.form.success({ - title: 'Success', - text: `Successfully delete key ${truncate(key.Id, { length: 7 })}` - }) - }) - } - - return () => { - return { - ...mgmt, - saveSiteConfig, - deleteIdentity - } - } -})() - -export const useStatus = (() => { - //Bypass the window focus check for the options page - const status = _sts(sendMessage, true); - return () => { - const refs = status.toRefs(); - //run status when called and dont await - status.update(); - return refs; - } -})() diff --git a/extension/src/bg-api/popup.ts b/extension/src/bg-api/popup.ts deleted file mode 100644 index f81bcfb..0000000 --- a/extension/src/bg-api/popup.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { useManagment as _mgmt, useStatus as _sts } from "./bg-api" -import { sendMessage } from "webext-bridge/popup" -import { apiCall, debugLog } from "@vnuge/vnlib.browser" - -export const useManagment = (() =>{ - const mgmt = _mgmt(sendMessage); - - const login = async (token: string) => { - await apiCall(async ({ toaster }) => { - - //Send the login request to the background script - await sendMessage('login', { token }, 'background') - - toaster.form.success({ - title: 'Success', - text: 'Logged in successfully' - }) - }) - } - - const logout = async () => { - await apiCall(async ({ toaster }) => { - //Send the login request to the background script - await sendMessage('logout', {}, 'background') - - toaster.form.success({ - title: 'Success', - text: 'Successfully logged out' - }) - }) - } - - return () => { - return { - ...mgmt, - login, - logout - } - } -})() - -export const useStatus = (() =>{ - const status = _sts(sendMessage, false); - - return () => { - const refs = status.toRefs(); - //run status when called and dont await - status.update(); - return refs - } -})() \ No newline at end of file diff --git a/extension/src/bg-api/types.ts b/extension/src/bg-api/types.ts deleted file mode 100644 index 6fc2f84..0000000 --- a/extension/src/bg-api/types.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - - -import { ToRefs } from 'vue'; -import { NostrPubKey } from '../entries/background/types' -import { JsonObject } from "type-fest"; - -export interface ClientStatus extends JsonObject { - readonly loggedIn: boolean; - readonly userName: string; - readonly selectedKey?: NostrPubKey; - readonly darkMode: boolean; -} - -export interface NostrIdentiy extends NostrPubKey { - readonly UserName: string; - readonly ExistingKey: string; -} - -export interface SendMessageHandler { - (action: string, data: any, context: string): Promise -} - -export interface UseStatusResult { - toRefs: () => ToRefs, - update: () => Promise -} - -export interface PluginConfig extends JsonObject { - readonly apiUrl: string; - readonly accountBasePath: string; - readonly nostrEndpoint: string; - readonly heartbeat: boolean; - readonly maxHistory: number; - readonly darkMode: boolean; - readonly autoInject: boolean; -} \ No newline at end of file diff --git a/extension/src/entries/background/auth-api.ts b/extension/src/entries/background/auth-api.ts deleted file mode 100644 index 64a46e4..0000000 --- a/extension/src/entries/background/auth-api.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { debugLog, useAxios, usePkiAuth, useSession, useSessionUtils, useUser } from "@vnuge/vnlib.browser"; -import { AxiosInstance } from "axios"; -import { runtime } from "webextension-polyfill"; -import { BridgeMessage } from "webext-bridge"; -import { useSettings } from "./settings"; -import { JsonObject } from "type-fest"; -import { ClientStatus, LoginMessage } from "./types"; - -interface ApiHandle { - axios: AxiosInstance -} - -export interface ProectedHandler { - (message: T): Promise -} - -export interface MessageHandler { - (message: T): Promise -} - -export interface ApiMessageHandler { - (message: T, apiHandle: { axios: AxiosInstance }): Promise -} - - -export const useAuthApi = (() => { - - const { loggedIn } = useSession(); - const { clearLoginState } = useSessionUtils(); - const { logout, getProfile, heartbeat, userName } = useUser(); - const { currentConfig } = useSettings(); - - const apiCall = async (asyncFunc: (h: ApiHandle) => Promise): Promise => { - try { - //Get configured axios instance from vnlib - const axios = useAxios(null); - - //Exec the async function - return await asyncFunc({ axios }) - } catch (errMsg) { - debugLog(errMsg) - // See if the error has an axios response - throw { ...errMsg }; - } - } - - const handleMessage = (cbHandler: MessageHandler) => { - return (message: BridgeMessage): Promise => { - return cbHandler(message.data) - } - } - - const handleProtectedMessage = (cbHandler: ProectedHandler) => { - return (message: BridgeMessage): Promise => { - if (message.sender.context === 'options' || message.sender.context === 'popup') { - return cbHandler(message.data) - } - throw new Error('Unauthorized') - } - } - - const handleApiCall = (cbHandler: ApiMessageHandler) => { - return (message: BridgeMessage): Promise => { - return apiCall((m) => cbHandler(message.data, m)) - } - } - - const handleProtectedApicall = (cbHandler: ApiMessageHandler) => { - return (message: BridgeMessage): Promise => { - if (message.sender.context === 'options' || message.sender.context === 'popup') { - return apiCall((m) => cbHandler(message.data, m)) - } - throw new Error('Unauthorized') - } - } - - const onLogin = handleProtectedApicall(async (data : LoginMessage): Promise => { - //Perform login - const { login } = usePkiAuth(`${currentConfig.value.accountBasePath}/pki`); - await login(data.token) - return true; - }) - - const onLogout = handleProtectedApicall(async () : Promise => { - await logout() - //Cleanup after logout - clearLoginState() - }) - - const onGetProfile = handleProtectedApicall(() : Promise => getProfile()) - - const onGetStatus = async (): Promise => { - return { - //Logged in if the cookie is set and the api flag is set - loggedIn: loggedIn.value, - //username - userName: userName.value, - //dark mode flag - darkMode: currentConfig.value.darkMode - } - } - - //We can send post messages to the server heartbeat endpoint to get status - const runHeartbeat = async () => { - //Only run if the api thinks its logged in, and config is enabled - if (!loggedIn.value || currentConfig.value.heartbeat !== true) { - return - } - - try { - //Post against the heartbeat endpoint - await heartbeat() - } - catch (error) { - if (error.response?.status === 401 || error.response?.status === 403) { - //If we get a 401, the user is no longer logged in - clearLoginState() - } - } - } - - //Setup autoheartbeat - runtime.onInstalled.addListener(async () => { - //Configure interval to run every 5 minutes to update the status - setInterval(runHeartbeat, 60 * 1000); - - //Run immediately - runHeartbeat(); - }); - - return () => { - return{ - loggedIn, - apiCall, - handleMessage, - handleProtectedMessage, - handleApiCall, - handleProtectedApicall, - userName, - onLogin, - onLogout, - onGetProfile, - onGetStatus - } - } -})() \ No newline at end of file diff --git a/extension/src/entries/background/history.ts b/extension/src/entries/background/history.ts deleted file mode 100644 index b3f3733..0000000 --- a/extension/src/entries/background/history.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { runtime, storage } from "webextension-polyfill"; -import { useSettings } from "./settings"; -import { isEqual, remove } from "lodash"; -import { ref } from "vue"; - -const evHistory = ref([]); - -export interface HistoryEvent extends Object{ - -} - -export const useHistory = (() => { - const { currentConfig } = useSettings(); - - const pushEvent = (event: HistoryEvent) => { - - //Limit the history to 50 events - if (evHistory.value.length > currentConfig.value.maxHistory) { - evHistory.value.shift(); - } - - evHistory.value.push(event); - - //Save the history but dont wait for it - storage.local.set({ eventHistory: evHistory }); - } - - const getHistory = (): HistoryEvent[] => { - return [...evHistory.value]; - } - - const clearHistory = () => { - evHistory.value.length = 0; - storage.local.set({ eventHistory: evHistory }); - } - - const removeItem = (event: HistoryEvent) => { - //Remove the event from the history - remove(evHistory.value, (ev) => isEqual(ev, event)); - //Save the history but dont wait for it - storage.local.set({ eventHistory: evHistory }); - } - - const onStartup = async () => { - //Recover the history array - const { eventHistory } = await storage.local.get('eventHistory'); - - //Push the history into the array - evHistory.value.push(...eventHistory); - } - - //Reload the history on startup - runtime.onStartup.addListener(onStartup); - runtime.onInstalled.addListener(onStartup); - - return () =>{ - return { - pushEvent, - getHistory, - clearHistory, - removeItem - } - } -})() - - -//Listen for messages \ No newline at end of file diff --git a/extension/src/entries/background/identity-api.ts b/extension/src/entries/background/identity-api.ts deleted file mode 100644 index c835f7c..0000000 --- a/extension/src/entries/background/identity-api.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { NostrIdentiy } from "../../bg-api/types"; -import { useAuthApi } from "./auth-api"; -import { useSettings } from "./settings"; - -export const useIdentityApi = (() => { - - const { handleProtectedApicall } = useAuthApi(); - const { currentConfig } = useSettings(); - - const onCreateIdentity = handleProtectedApicall(async (data, { axios }) => { - //Create a new identity - const response = await axios.put(`${currentConfig.value.nostrEndpoint}?type=identity`, data) - - if (response.data.success) { - return response.data.result; - } - - //If we get here, the login failed - throw { response } - }) - - const onUpdateIdentity = handleProtectedApicall(async (data, { axios }) => { - delete data.Created; - delete data.LastModified; - - //Create a new identity - const response = await axios.patch(`${currentConfig.value.nostrEndpoint}?type=identity`, data) - - if (response.data.success) { - return response.data.result; - } - - //If we get here, the login failed - throw { response } - }) - - return () =>{ - return{ - onCreateIdentity, - onUpdateIdentity - } - } - -})() \ No newline at end of file diff --git a/extension/src/entries/background/main.ts b/extension/src/entries/background/main.ts index 7573b7a..1d74f26 100644 --- a/extension/src/entries/background/main.ts +++ b/extension/src/entries/background/main.ts @@ -13,93 +13,32 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { runtime } from "webextension-polyfill"; -import { useHistory } from "./history"; -import { useNostrApi } from "./nostr-api"; -import { useIdentityApi } from "./identity-api"; -import { useSettings } from "./settings"; -import { onMessage } from "webext-bridge/background"; -import { useAuthApi } from "./auth-api"; -import { JsonObject } from "type-fest"; - -//Init the history api -useHistory(); - -runtime.onInstalled.addListener(() => { - console.info("Extension installed successfully"); -}); - - -//Register settings handlers -const { onGetSiteConfig, onSetSitConfig } = useSettings(); - -onMessage('getSiteConfig', onGetSiteConfig); -onMessage('setSiteConfig', onSetSitConfig); - -//Register the api handlers -const { onGetProfile, onGetStatus, onLogin, onLogout, handleProtectedMessage } = useAuthApi(); - -onMessage('getProfile', onGetProfile); -onMessage('getStatus', onGetStatus); -onMessage('login', onLogin); -onMessage('logout', onLogout); - -//Register the identity handlers -const { onCreateIdentity, onUpdateIdentity } = useIdentityApi(); - -onMessage('createIdentity', onCreateIdentity); -onMessage('updateIdentity', onUpdateIdentity); - -//Register the nostr handlers -const { - onGetPubKey, - onSelectKey, - onSignEvent, - onGetAllKeys, - onGetRelays, - onNip04Decrypt, - onNip04Encrypt, - onDeleteKey, - onSetRelay -} = useNostrApi(); - -onMessage('getPublicKey', onGetPubKey); -onMessage('selectKey', onSelectKey); -onMessage('signEvent', onSignEvent); -onMessage('getAllKeys', onGetAllKeys); -onMessage('getRelays', onGetRelays); -onMessage('setRelay', onSetRelay); -onMessage('deleteKey', onDeleteKey); -onMessage('nip04.decrypt', onNip04Decrypt); -onMessage('nip04.encrypt', onNip04Encrypt); - -//Use history api -const { getHistory, clearHistory, removeItem, pushEvent } = useHistory(); - -enum HistoryType { - get = 'get', - clear = 'clear', - remove = 'remove', - push = 'push' -} - -interface HistoryMessage extends JsonObject { - action: HistoryType, - event: string -} - -onMessage('history', handleProtectedMessage(async (data) =>{ - switch(data.action){ - case HistoryType.get: - return getHistory(); - case HistoryType.clear: - clearHistory(); - break; - case HistoryType.remove: - removeItem(data.event); - break; - case HistoryType.push: - pushEvent(data.event); - break; - } -})) \ No newline at end of file +import { + useHistoryApi, + useNostrApi, + useIdentityApi, + useSettingsApi, + useAuthApi, + useLocalPki, + useAppSettings, + usePkiApi, + useEventTagFilterApi, + useInjectAllowList +} from "../../features"; +import { useBackgroundFeatures } from "../../features/framework"; + +const settings = useAppSettings(); +const { register } = useBackgroundFeatures(settings) + +//Resgiter background features +register([ + useSettingsApi, + useAuthApi, + useIdentityApi, + useHistoryApi, + useNostrApi, + useLocalPki, + usePkiApi, + useEventTagFilterApi, + useInjectAllowList +]) \ No newline at end of file diff --git a/extension/src/entries/background/nostr-api.ts b/extension/src/entries/background/nostr-api.ts deleted file mode 100644 index 3c862ff..0000000 --- a/extension/src/entries/background/nostr-api.ts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { useSettings } from "./settings"; -import { useAuthApi } from "./auth-api"; -import { computed, ref, watch } from "vue"; - -import { find, isArray } from "lodash"; -import { Endpoints, initApi } from "./server-api"; -import { NostrRelay, NostrPubKey, EventMessage, NostrEvent } from './types' - -export const useNostrApi = (() => { - - const { currentConfig } = useSettings(); - const { handleProtectedApicall, handleApiCall, handleProtectedMessage, loggedIn } = useAuthApi(); - - const nostrUrl = computed(() => currentConfig.value.nostrEndpoint || '/nostr') - - //Init the api endpooints - const { execRequest } = initApi(nostrUrl); - - //Get the current selected key - const selectedKey = ref({} as NostrPubKey) - - //Selected key is allowed from content script - const onGetPubKey = () => ({ ...selectedKey.value }); - - const onDeleteKey = handleProtectedApicall(data => execRequest(Endpoints.DeleteKey, data)) - - //Set the selected key to the value - const onSelectKey = handleProtectedMessage(data => (selectedKey.value = data, Promise.resolve())); - - const onGetAllKeys = handleProtectedApicall(async () => { - //Get the keys from the server - const data = await execRequest(Endpoints.GetKeys); - - //Response must be an array of key objects - if (!isArray(data)) { - return []; - } - - //Make sure the selected keyid is in the list, otherwise unselect the key - if (data?.length > 0) { - if (!find(data, k => k.Id === selectedKey.value?.Id)) { - selectedKey.value = null; - } - } - - return [...data] - }) - - //Unprotect the signing handler so it can be called from the content script - const onSignEvent = handleApiCall(async (data) => { - //Set the key id from our current selection - data.event.KeyId = selectedKey.value?.Id || ''; //Pass key selection error to server - //Sign the event - const event = await execRequest(Endpoints.SignEvent, data.event); - return { event }; - }) - - const onGetRelays = handleApiCall(async () => { - //Get preferred relays for the current user - const data = await execRequest(Endpoints.GetRelays) - return [...data] - }) - - const onSetRelay = handleProtectedApicall(data => execRequest(Endpoints.SetRelay, data)); - - - const onNip04Encrypt = handleProtectedMessage(async (data) => { - console.log('nip04.encrypt', data) - return { ciphertext: 'ciphertext' } - }) - - const onNip04Decrypt = handleProtectedMessage(async (data) => { - console.log('nip04.decrypt', data) - return { plaintext: 'plaintext' } - }) - - //Clear the selected key if the user logs out - watch(loggedIn, (li) => li ? null : selectedKey.value = null) - - return () => { - return{ - selectedKey, - nostrUrl, - onGetPubKey, - onSelectKey, - onGetAllKeys, - onSignEvent, - onGetRelays, - onSetRelay, - onNip04Encrypt, - onNip04Decrypt, - onDeleteKey - } - } -})() \ No newline at end of file diff --git a/extension/src/entries/background/permissions.ts b/extension/src/entries/background/permissions.ts deleted file mode 100644 index 4732bb7..0000000 --- a/extension/src/entries/background/permissions.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { useStorageAsync } from "@vueuse/core"; -import { find, isEmpty, merge, remove } from "lodash"; -import { storage } from "webextension-polyfill"; -import { useAuthApi } from "./auth-api"; -import { useSettings } from "./settings"; - -const permissions = useStorageAsync("permissions", [], storage.local); - -export const setAutoAllow = async (origin, mKind, keyId) => { - permissions.value.push({ origin, mKind, keyId, }) -} - -/** - * Determines if the user has previously allowed the origin to use the key to sign events - * of the desired kind - * @param {*} origin The site origin requesting the permission - * @param {*} mKind The kind of message being signed - * @param {*} keyId The keyId of the key being used to sign the message - */ -export const isAutoAllow = async (origin, mKind, keyId) => { - return find(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId) !== undefined -} - -/** - * Removes the auto allow permission from the list - * @param {*} origin The site origin requesting the permission - * @param {*} mKind The message kind being signed - * @param {*} keyId The keyId of the key being used to sign the message - */ -export const removeAutoAllow = async (origin, mKind, keyId) => { - //Remove the permission from the list - remove(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId); -} - - -export const useSitePermissions = (() => { - - const { apiCall, handleProtectedMessage } = useAuthApi(); - const { currentConfig } = useSettings(); - - - const getCurrentPerms = async () => { - const { permissions } = await storage.local.get('permissions'); - - //Store a default config if none exists - if (isEmpty(permissions)) { - await storage.local.set({ siteConfig: defaultConfig }); - } - - //Merge the default config with the site config - return merge(defaultConfig, siteConfig) - } - - const onIsSiteEnabled = handleProtectedMessage(async (data) => { - - }) - - return () => { - return { - onCreateIdentity, - onUpdateIdentity - } - } - -})() \ No newline at end of file diff --git a/extension/src/entries/background/server-api/endpoints.ts b/extension/src/entries/background/server-api/endpoints.ts deleted file mode 100644 index 6da9c71..0000000 --- a/extension/src/entries/background/server-api/endpoints.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - - -import { useAxios } from "@vnuge/vnlib.browser"; -import { Method } from "axios"; - -export interface EndpointDefinition { - readonly method: Method - path(...request: any): string - onRequest: (...request: any) => Promise - onResponse: (response: any, request?: any) => Promise -} - -export interface EndpointDefinitionReg extends EndpointDefinition { - readonly id: T -} - -export const initEndponts = () => { - - const endpoints = new Map(); - - const registerEndpoint = (def: EndpointDefinitionReg) => { - //Store the handler by its id - endpoints.set(def.id, def); - return def; - } - - const getEndpoint = (id: T): EndpointDefinition | undefined => { - return endpoints.get(id); - } - - const execRequest = async (id: string, ...request: any): Promise => { - const endpoint = getEndpoint(id); - if (!endpoint) { - throw new Error(`Endpoint ${id} not found`); - } - - //Compute the path from the request - const path = endpoint.path(...request); - - //Execute the request handler - const req = await endpoint.onRequest(...request); - - //Get axios - const axios = useAxios(null); - - //Exec the request - const { data } = await axios({ method: endpoint.method, url: path, data: req }); - - //exec the response handler and return its result - return await endpoint.onResponse(data, request); - } - - return { - registerEndpoint, - getEndpoint, - execRequest - } -} diff --git a/extension/src/entries/background/server-api/index.ts b/extension/src/entries/background/server-api/index.ts deleted file mode 100644 index 84598df..0000000 --- a/extension/src/entries/background/server-api/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - - -import { Ref } from "vue" -import { get } from '@vueuse/core' -import { WebMessage } from "@vnuge/vnlib.browser" -import { initEndponts } from "./endpoints" -import { NostrEvent, NostrRelay } from "../types" -import { NostrIdentiy } from "../../../bg-api/types" - -export enum Endpoints { - GetKeys = 'getKeys', - DeleteKey = 'deleteKey', - SignEvent = 'signEvent', - GetRelays = 'getRelays', - SetRelay = 'setRelay', - Encrypt = 'encrypt', - Decrypt = 'decrypt', -} - -export const initApi = (nostrUrl: Ref) => { - const { registerEndpoint, execRequest } = initEndponts() - - registerEndpoint({ - id: Endpoints.GetKeys, - method: 'GET', - path: () => `${get(nostrUrl)}?type=getKeys`, - onRequest: () => Promise.resolve(), - onResponse: (response) => Promise.resolve(response) - }) - - registerEndpoint({ - id: Endpoints.DeleteKey, - method: 'DELETE', - path: (key:NostrIdentiy) => `${get(nostrUrl)}?type=identity&key_id=${key.Id}`, - onRequest: () => Promise.resolve(), - onResponse: (response: WebMessage) => response.getResultOrThrow() - }) - - registerEndpoint({ - id: Endpoints.SignEvent, - method: 'POST', - path: () => `${get(nostrUrl)}?type=signEvent`, - onRequest: (event) => Promise.resolve(event), - onResponse: async (response: WebMessage) => { - const res = response.getResultOrThrow() - delete res.KeyId; - return res; - } - }) - - registerEndpoint({ - id: Endpoints.GetRelays, - method: 'GET', - path: () => `${get(nostrUrl)}?type=getRelays`, - onRequest: () => Promise.resolve(), - onResponse: (response) => Promise.resolve(response) - }) - - registerEndpoint({ - id: Endpoints.SetRelay, - method: 'POST', - path: () => `${get(nostrUrl)}?type=relay`, - onRequest: (relay:NostrRelay) => Promise.resolve(relay), - onResponse: (response) => Promise.resolve(response) - }) - - return { - execRequest - } -} \ No newline at end of file diff --git a/extension/src/entries/background/settings.ts b/extension/src/entries/background/settings.ts deleted file mode 100644 index 98d6aa6..0000000 --- a/extension/src/entries/background/settings.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { runtime, storage } from "webextension-polyfill" -import { isEmpty, isEqual, merge } from 'lodash' -import { configureApi, debugLog } from '@vnuge/vnlib.browser' -import { readonly, ref } from "vue"; -import { BridgeMessage } from "webext-bridge"; -import { JsonObject } from "type-fest"; - -export interface PluginConfig extends JsonObject { - readonly apiUrl: string; - readonly accountBasePath: string; - readonly nostrEndpoint: string; - readonly heartbeat: boolean; - readonly maxHistory: number; - readonly darkMode: boolean; - readonly autoInject: boolean; -} - -//Default storage config -const defaultConfig : PluginConfig = { - apiUrl: import.meta.env.VITE_API_URL, - accountBasePath: import.meta.env.VITE_ACCOUNTS_BASE_PATH, - nostrEndpoint: import.meta.env.VITE_NOSTR_ENDPOINT, - heartbeat: import.meta.env.VITE_HEARTBEAT_ENABLED === 'true', - maxHistory: 50, - darkMode: false, - autoInject: true -}; - -export const useSettings = (() =>{ - - const currentConfig = ref({} as PluginConfig); - - const getCurrentConfig = async () => { - const { siteConfig } = await storage.local.get('siteConfig'); - - //Store a default config if none exists - if(isEmpty(siteConfig)){ - await storage.local.set({ siteConfig: defaultConfig }); - } - - //Merge the default config with the site config - return merge(defaultConfig, siteConfig) - } - - const restoreApiSettings = async () => { - //Set the current config - currentConfig.value = await getCurrentConfig();; - - //Configure the vnlib api - configureApi({ - session: { - cookiesEnabled: false, - bidSize: 32, - storage: localStorage - }, - user: { - accountBasePath: currentConfig.value.accountBasePath, - storage: localStorage, - }, - axios: { - baseURL: currentConfig.value.apiUrl, - tokenHeader: import.meta.env.VITE_WEB_TOKEN_HEADER, - } - }) - } - - const saveConfig = async (config: PluginConfig) : Promise => { - await storage.local.set({ siteConfig: config }); - } - - const onGetSiteConfig = async ({ } :BridgeMessage): Promise => { - return { ...currentConfig.value } - } - - const onSetSitConfig = async ({ sender, data }: BridgeMessage) : Promise => { - //Config messages should only come from the options page - if (sender.context !== 'options') { - throw new Error('Unauthorized'); - } - - //Save the config - await saveConfig(data); - - //Restore the api settings - restoreApiSettings(); - - debugLog('Config settings saved!'); - } - - runtime.onInstalled.addListener(() => { - restoreApiSettings(); - debugLog('Server settings restored from storage'); - }); - - runtime.onConnect.addListener(async () => { - //refresh the config on connect - currentConfig.value = await getCurrentConfig(); - }) - - return () =>{ - return{ - getCurrentConfig, - restoreApiSettings, - saveConfig, - currentConfig:readonly(currentConfig), - onGetSiteConfig, - onSetSitConfig - } - } -})() \ No newline at end of file diff --git a/extension/src/entries/background/types.ts b/extension/src/entries/background/types.ts deleted file mode 100644 index d459ea1..0000000 --- a/extension/src/entries/background/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (C) 2023 Vaughn Nugent -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { JsonObject } from "type-fest"; - -export interface NostrPubKey extends JsonObject { - readonly Id: string, - readonly UserName: string, - readonly PublicKey: string, - readonly Created: string, - readonly LastModified: string -} - -export interface NostrEvent extends JsonObject { - KeyId: string, - readonly id: string, - readonly pubkey: string, - readonly content: string, -} - -export interface EventMessage extends JsonObject { - readonly event: NostrEvent -} - -export interface NostrRelay extends JsonObject { - readonly Id: string, - readonly url: string, - readonly flags: number, - readonly Created: string, - readonly LastModified: string -} - -export interface LoginMessage extends JsonObject { - readonly token: string -} - -export interface ClientStatus { - readonly loggedIn: boolean; - readonly userName: string | null; - readonly darkMode: boolean; -} - -export enum NostrRelayFlags { - None = 0, - Default = 1, - Preferred = 2, -} - -export enum NostrRelayMessageType{ - updateRelay = 1, - addRelay = 2, - deleteRelay = 3 -} - -export interface NostrRelayMessage extends JsonObject { - readonly relay: NostrRelay - readonly type: NostrRelayMessageType -} - -export interface LoginMessage extends JsonObject { - readonly token: string - readonly username: string - readonly password: string -} \ No newline at end of file diff --git a/extension/src/entries/contentScript/nostr-shim.js b/extension/src/entries/contentScript/nostr-shim.js index d0ca973..eddc678 100644 --- a/extension/src/entries/contentScript/nostr-shim.js +++ b/extension/src/entries/contentScript/nostr-shim.js @@ -13,42 +13,37 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - -import { runtime } from "webextension-polyfill"; -import { isEqual, isNil, isEmpty } from 'lodash' -import { sendMessage } from 'webext-bridge/content-script' +import { runtime } from "webextension-polyfill" +import { isEqual, isNil, isEmpty } from 'lodash' import { apiCall } from '@vnuge/vnlib.browser' -import { useManagment } from './../../bg-api/content-script' +import { useScriptTag, watchOnce } from "@vueuse/core" +import { createPort } from '../../webext-bridge/' +import { useStore } from '../store' +import { storeToRefs } from 'pinia' -const { getSiteConfig } = useManagment() -const nip07Enabled = () => getSiteConfig().then(p => p.autoInject); +const _promptHandler = (() => { + let _handler = undefined; + return{ + invoke: (event) => _handler(event), + set: (handler) => _handler = handler + } +})() -//Setup listener for the content script to process nostr messages +export const usePrompt = (callback) => _promptHandler.set(callback); -const ext = '@vnuge/nvault-extension' -let _promptHandler = () => Promise.resolve({}) +export const onLoad = async () =>{ -export const usePrompt = (callback) => { - //Register the callback - _promptHandler = async (event) => { - return new Promise((resolve, reject) => { - callback(event).then(resolve).catch(reject) - }) - } - return {} -} + const injectHandler = () => { + + //Setup listener for the content script to process nostr messages + const ext = '@vnuge/nvault-extension' + const { sendMessage } = createPort('content-script') + + const scriptUrl = runtime.getURL('src/entries/nostr-provider.js') -//Only inject the script if the site has autoInject enabled -nip07Enabled().then(enabled => { - console.log('Nip07 enabled:', enabled) - if (enabled) { - // inject the script that will provide window.nostr - let script = document.createElement('script'); - script.setAttribute('async', 'false'); - script.setAttribute('type', 'text/javascript'); - script.setAttribute('src', runtime.getURL('src/entries/nostr-provider.js')); - document.head.appendChild(script); + //setup script tag + useScriptTag(scriptUrl, undefined, { manual: false, defer: true }) //Only listen for messages if injection is enabled window.addEventListener('message', async ({ source, data, origin }) => { @@ -72,9 +67,9 @@ nip07Enabled().then(enabled => { case 'nip04.encrypt': case 'nip04.decrypt': //await propmt for user to allow the request - const allow = await _promptHandler({ ...data, origin }) + const allow = await _promptHandler.invoke({ ...data, origin }) //send request to background - response = allow ? await sendMessage(data.type, { ...data.payload, origin }) : { error: 'User denied permission' } + response = allow ? await sendMessage(data.type, { ...data.payload, origin }, 'background') : { error: 'User denied permission' } break; default: throw new Error('Unknown nostr message type') @@ -84,4 +79,18 @@ nip07Enabled().then(enabled => { window.postMessage({ ext, id: data.id, response }, origin); }); } -}) + + const store = useStore() + const { isTabAllowed } = storeToRefs(store) + + //Make sure the origin is allowed + if (store.isTabAllowed === false){ + //If not allowed yet, wait for the store to update + watchOnce(isTabAllowed, val => val ? injectHandler() : undefined); + return; + } + else{ + injectHandler(); + } + +} \ No newline at end of file diff --git a/extension/src/entries/contentScript/primary/components/PromptPopup.vue b/extension/src/entries/contentScript/primary/components/PromptPopup.vue index 3747c57..195c6db 100644 --- a/extension/src/entries/contentScript/primary/components/PromptPopup.vue +++ b/extension/src/entries/contentScript/primary/components/PromptPopup.vue @@ -10,8 +10,8 @@ Identity:
-
- {{ selectedKey?.UserName ?? 'Select Identity' }} +
+ {{ keyName ?? 'Select Identity' }}
@@ -65,14 +65,17 @@ \ No newline at end of file diff --git a/extension/src/entries/options/components/SiteSettings.vue b/extension/src/entries/options/components/SiteSettings.vue index 2b32a93..c0c2e93 100644 --- a/extension/src/entries/options/components/SiteSettings.vue +++ b/extension/src/entries/options/components/SiteSettings.vue @@ -7,23 +7,23 @@ Extension settings
-
+
- NIP-07 + Origin protection
- Always on NIP-07 + Tracking protection
@@ -69,27 +69,42 @@
-
+
- +

* The http path to the vault server (must start with http:// or https://)

- +

* This is the path to the account server endpoint (must start with /)

- +

* This is the path to the Nostr plugin endpoint path (must start with /)

@@ -104,25 +119,31 @@ diff --git a/extension/src/entries/options/main.js b/extension/src/entries/options/main.js index b9892ba..827c426 100644 --- a/extension/src/entries/options/main.js +++ b/extension/src/entries/options/main.js @@ -22,12 +22,23 @@ import Notifications from "@kyvg/vue3-notification"; /* FONT AWESOME CONFIG */ import { library } from '@fortawesome/fontawesome-svg-core' -import { faChevronLeft, faChevronRight, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMoon, faSun, faTrash } from '@fortawesome/free-solid-svg-icons' +import { faChevronLeft, faChevronRight, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMoon, faPlus, faSun, faTrash } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import { createPinia } from "pinia"; +import { identityPlugin, originPlugin, useBackgroundPiniaPlugin } from "../store"; -library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload, faChevronRight) +library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload, faChevronRight, faPlus) + +//Create the background feature wiring +const bgPlugins = useBackgroundPiniaPlugin('options') + +const pinia = createPinia() + .use(bgPlugins) //Add the background pinia plugin + .use(identityPlugin) //Add the identity plugin + .use(originPlugin) //Add the origin plugin createApp(App) .use(Notifications) + .use(pinia) .component('fa-icon', FontAwesomeIcon) - .mount("#app"); \ No newline at end of file + .mount("#app"); diff --git a/extension/src/entries/popup/App.vue b/extension/src/entries/popup/App.vue index 0181bbb..8c49fa6 100644 --- a/extension/src/entries/popup/App.vue +++ b/extension/src/entries/popup/App.vue @@ -17,6 +17,6 @@ main { text-align: center; color: #2c3e50; - @apply dark:bg-dark-800 bg-white dark:text-gray-200; + @apply dark:bg-dark-900 bg-white dark:text-gray-200; } diff --git a/extension/src/entries/popup/Components/IdentitySelection.vue b/extension/src/entries/popup/Components/IdentitySelection.vue index d1a7333..99d8e34 100644 --- a/extension/src/entries/popup/Components/IdentitySelection.vue +++ b/extension/src/entries/popup/Components/IdentitySelection.vue @@ -1,5 +1,5 @@ diff --git a/extension/src/entries/popup/Components/PageContent.vue b/extension/src/entries/popup/Components/PageContent.vue index eda9bab..1a3995e 100644 --- a/extension/src/entries/popup/Components/PageContent.vue +++ b/extension/src/entries/popup/Components/PageContent.vue @@ -4,35 +4,49 @@ class="flex flex-col text-left w-[20rem] min-h-[25rem]" > -
-
-

NVault

+
+
+
+ +
+

NVault

+
+ {{ isTabAllowed ? 'Active' : 'Inactive' }} +
-
-
-
+
-
-
-
- {{ userName }} -
+ +
+
+ +
+ {{ userName }} +
+ +
+
-
-
- -
+ +
+
+
{{ pubKey ?? 'No key selected' }}
@@ -40,13 +54,30 @@
-
-
- Always on NIP-07: {{ autoInject }} + +
+ + +
+ + + + +
+ +
+ Tracking protection disabled +
-
+
@@ -57,9 +88,10 @@ - - + diff --git a/extension/src/entries/popup/local.scss b/extension/src/entries/popup/local.scss new file mode 100644 index 0000000..9c64b98 --- /dev/null +++ b/extension/src/entries/popup/local.scss @@ -0,0 +1,18 @@ + +.toaster { + position: fixed; + top: 15px; + right: 0; + z-index: 9999; + max-width: 230px; +} + +.active-badge{ + @apply text-white; + &.active{ + @apply bg-primary-500 dark:bg-primary-600; + } + &.inactive{ + + } +} \ No newline at end of file diff --git a/extension/src/entries/popup/main.js b/extension/src/entries/popup/main.js index 4a32bb7..a259e63 100644 --- a/extension/src/entries/popup/main.js +++ b/extension/src/entries/popup/main.js @@ -14,19 +14,30 @@ // along with this program. If not, see . import { createApp } from "vue"; +import { createPinia } from "pinia"; +import { identityPlugin, originPlugin, useBackgroundPiniaPlugin } from '../store' import App from "./App.vue"; import Notifications from "@kyvg/vue3-notification"; import '@fontsource/noto-sans-masaram-gondi' import "~/assets/all.scss"; +import "./local.scss" /* FONT AWESOME CONFIG */ import { library } from '@fortawesome/fontawesome-svg-core' -import { faArrowRightFromBracket, faCopy, faEdit, faGear, faSpinner } from '@fortawesome/free-solid-svg-icons' +import { faArrowRightFromBracket, faCopy, faEdit, faGear, faMinus, faPlus, faSpinner } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' -library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket) +library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket, faPlus, faMinus) + +const bgPlugin = useBackgroundPiniaPlugin('popup') + +const pinia = createPinia() + .use(bgPlugin) //Add the background pinia plugin + .use(identityPlugin) + .use(originPlugin) createApp(App) .use(Notifications) + .use(pinia) .component('fa-icon', FontAwesomeIcon) .mount("#app"); diff --git a/extension/src/entries/store/allowedOrigins.ts b/extension/src/entries/store/allowedOrigins.ts new file mode 100644 index 0000000..7fc5e15 --- /dev/null +++ b/extension/src/entries/store/allowedOrigins.ts @@ -0,0 +1,43 @@ + +import 'pinia' +import { } from 'lodash' +import { PiniaPluginContext } from 'pinia' +import { computed, ref } from 'vue'; +import { onWatchableChange } from '../../features/types'; +import { type AllowedOriginStatus } from '../../features/nip07allow-api'; + +declare module 'pinia' { + export interface PiniaCustomProperties { + isTabAllowed: boolean; + currentOrigin: string | undefined; + allowedOrigins: Array; + isOriginProtectionOn: boolean; + allowOrigin(origin?:string): Promise; + dissallowOrigin(origin?:string): Promise; + disableOriginProtection(): Promise; + setOriginProtection(value: boolean): Promise; + } +} + +export const originPlugin = ({ store }: PiniaPluginContext) => { + + const { plugins } = store + const status = ref() + + onWatchableChange(plugins.allowedOrigins, async () => { + //Update the status + status.value = await plugins.allowedOrigins.getStatus() + }, { immediate: true }) + + return { + allowedOrigins: computed(() => status.value?.allowedOrigins || []), + isTabAllowed: computed(() => status.value?.isAllowed == true), + currentOrigin: computed(() => status.value?.currentOrigin), + isOriginProtectionOn: computed(() => status.value?.enabled == true), + //Push to the allow list, will trigger a change if needed + allowOrigin: plugins.allowedOrigins.addOrigin, + //Remove from allow list, will trigger a change if needed + dissallowOrigin: plugins.allowedOrigins.removeOrigin, + setOriginProtection: plugins.allowedOrigins.enable + } +} diff --git a/extension/src/entries/store/features.ts b/extension/src/entries/store/features.ts new file mode 100644 index 0000000..9bf3052 --- /dev/null +++ b/extension/src/entries/store/features.ts @@ -0,0 +1,116 @@ + +import 'pinia' +import { } from 'lodash' +import { PiniaPluginContext } from 'pinia' +import { type Tabs, tabs } from 'webextension-polyfill' + +import { + SendMessageHandler, + useAuthApi, + useHistoryApi, + useIdentityApi, + useNostrApi, + useLocalPki, + usePkiApi, + useSettingsApi, + useForegoundFeatures, + useEventTagFilterApi, + useInjectAllowList +} from "../../features" + +import { RuntimeContext, createPort } from '../../webext-bridge' +import { ref } from 'vue' +import { onWatchableChange } from '../../features/types' + +export type BgPlugins = ReturnType +export type BgPluginState = { plugins: BgPlugins } & T + +declare module 'pinia' { + export interface PiniaCustomProperties { + plugins: BgPlugins + currentTab: Tabs.Tab | undefined + } +} + +const usePlugins = (sendMessage: SendMessageHandler) => { + //Create plugin wrapping function + const { use } = useForegoundFeatures(sendMessage) + + return { + settings: use(useSettingsApi), + user: use(useAuthApi), + identity: use(useIdentityApi), + nostr: use(useNostrApi), + history: use(useHistoryApi), + localPki: use(useLocalPki), + pki: use(usePkiApi), + tagFilter: use(useEventTagFilterApi), + allowedOrigins: use(useInjectAllowList) + } +} + +export const useBackgroundPiniaPlugin = (context: RuntimeContext) => { + //Create port for context + const { sendMessage } = createPort(context) + const plugins = usePlugins(sendMessage) + const { user } = plugins; + + const currentTab = ref(undefined) + + //Plugin store + return ({ store }: PiniaPluginContext) => { + + //watch for status changes + onWatchableChange(user, async () => { + //Get status update and set the values + const { loggedIn, userName } = await user.getStatus(); + store.loggedIn = loggedIn; + store.userName = userName; + + }, { immediate: true }) + + //Wait for settings changes + onWatchableChange(plugins.settings, async () => { + + //Update settings and dark mode on change + store.settings = await plugins.settings.getSiteConfig(); + store.darkMode = await plugins.settings.getDarkMode(); + console.log("Settings changed") + }, { immediate: true }) + + + const initTab = async () => { + + if(!tabs){ + return; + } + + //Get the current tab + const [active] = await tabs.query({ active: true, currentWindow: true }) + currentTab.value = active + + //Watch for changes to the current tab + tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + //If the url changed, update the current tab + if (changeInfo.url) { + currentTab.value = tab + } + }) + + tabs.onActivated.addListener(async ({ tabId }) => { + //Get the tab + const tab = await tabs.get(tabId) + //Update the current tab + currentTab.value = tab + }) + } + + + initTab() + + return{ + plugins, + currentTab, + } + } +} \ No newline at end of file diff --git a/extension/src/entries/store/identity.ts b/extension/src/entries/store/identity.ts new file mode 100644 index 0000000..58a6b67 --- /dev/null +++ b/extension/src/entries/store/identity.ts @@ -0,0 +1,43 @@ + +import 'pinia' +import { } from 'lodash' +import { PiniaPluginContext } from 'pinia' +import { NostrPubKey } from '../../features' +import { ref } from 'vue'; +import { onWatchableChange } from '../../features/types'; + +declare module 'pinia' { + export interface PiniaCustomStateProperties { + allKeys: NostrPubKey[]; + selectedKey: NostrPubKey | undefined; + deleteIdentity(key: Partial): Promise; + createIdentity(id: Partial): Promise; + updateIdentity(id: NostrPubKey): Promise; + selectKey(key: NostrPubKey): Promise; + } +} + + +export const identityPlugin = ({ store }: PiniaPluginContext) => { + + const { identity } = store.plugins + + const allKeys = ref([]) + const selectedKey = ref(undefined) + + onWatchableChange(identity, async () => { + allKeys.value = await identity.getAllKeys(); + //Get the current key + selectedKey.value = await identity.getPublicKey(); + console.log('Selected key is now', selectedKey.value) + }, { immediate:true }) + + return { + selectedKey, + allKeys, + selectKey: identity.selectKey, + deleteIdentity: identity.deleteIdentity, + createIdentity: identity.createIdentity, + updateIdentity: identity.updateIdentity + } +} \ No newline at end of file diff --git a/extension/src/entries/store/index.ts b/extension/src/entries/store/index.ts new file mode 100644 index 0000000..07fce6d --- /dev/null +++ b/extension/src/entries/store/index.ts @@ -0,0 +1,60 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import 'pinia' +import { } from 'lodash' +import { defineStore } from 'pinia' +import { PluginConfig } from '../../features/' +import { NostrStoreState } from './types' + +export type * from './types' +export * from './allowedOrigins' +export * from './features' +export * from './identity' + +export const useStore = defineStore({ + id: 'main', + state: (): NostrStoreState =>({ + loggedIn: false, + userName: '', + settings: undefined as any, + darkMode: false + }), + actions: { + + async login (token: string) { + await this.plugins.user.login(token); + }, + + async logout () { + await this.plugins.user.logout(); + }, + + saveSiteConfig(config: PluginConfig) { + return this.plugins.settings.setSiteConfig(config) + }, + + async toggleDarkMode(){ + await this.plugins.settings.setDarkMode(this.darkMode === false) + }, + + checkIsCurrentOriginAllowed() { + + } + }, + getters:{ + + }, +}) \ No newline at end of file diff --git a/extension/src/entries/store/types.ts b/extension/src/entries/store/types.ts new file mode 100644 index 0000000..7addda4 --- /dev/null +++ b/extension/src/entries/store/types.ts @@ -0,0 +1,9 @@ +import { } from "webextension-polyfill"; +import { PluginConfig } from "../../features"; + +export interface NostrStoreState { + loggedIn: boolean; + userName: string | null; + settings: PluginConfig; + darkMode: boolean; +} \ No newline at end of file diff --git a/extension/src/features/account-api.ts b/extension/src/features/account-api.ts new file mode 100644 index 0000000..9c701c3 --- /dev/null +++ b/extension/src/features/account-api.ts @@ -0,0 +1,190 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useMfaConfig, usePkiConfig, PkiPublicKey, debugLog } from "@vnuge/vnlib.browser"; +import { ArrayToHexString, Base64ToUint8Array } from "@vnuge/vnlib.browser/dist/binhelpers"; +import { JsonObject } from "type-fest"; +import { useSingleSlotStorage } from "./types"; +import { computed, watch } from "vue"; +import { storage } from "webextension-polyfill"; +import { JWK, SignJWT, importJWK } from "jose"; +import { cloneDeep } from "lodash"; +import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly, popupAndOptionsOnly } from "./framework"; +import { AppSettings } from "./settings"; + + +export interface EcKeyParams extends JsonObject { + readonly namedCurve: string +} + +export interface PkiPubKey extends JsonObject, PkiPublicKey { + readonly kid: string, + readonly alg: string, + readonly use: string, + readonly kty: string, + readonly x: string, + readonly y: string, + readonly serial: string + readonly userName: string +} + +export interface PkiApi extends FeatureApi{ + getAllKeys(): Promise + removeKey(kid: PkiPubKey): Promise + isEnabled(): Promise +} + +export const usePkiApi = (): IFeatureExport => { + return{ + background: ({ state } : BgRuntime):PkiApi =>{ + const accountPath = computed(() => state.currentConfig.value.accountBasePath) + const mfaEndpoint = computed(() => `${accountPath.value}/mfa`) + const pkiEndpoint = computed(() => `${accountPath.value}/pki`) + + //Compute config + const mfaConfig = useMfaConfig(mfaEndpoint); + const pkiConfig = usePkiConfig(pkiEndpoint, mfaConfig); + + //Refresh the config when the endpoint changes + watch(mfaEndpoint, () => pkiConfig.refresh()); + + return{ + getAllKeys: optionsOnly(async () => { + const res = await pkiConfig.getAllKeys(); + return res as PkiPubKey[] + }), + removeKey: optionsOnly(async (key: PkiPubKey) => { + await pkiConfig.removeKey(key.kid) + }), + isEnabled: popupAndOptionsOnly(async () => { + return pkiConfig.enabled.value + }) + } + }, + foreground: exportForegroundApi([ + 'getAllKeys', + 'removeKey', + 'isEnabled' + ]) + } +} + +interface PkiSettings { + userName: string, + privateKey?:JWK +} + +export interface LocalPkiApi extends FeatureApi { + regenerateKey: (userName:string, params: EcKeyParams) => Promise + getPubKey: () => Promise + generateOtp: () => Promise +} + +export const useLocalPki = (): IFeatureExport => { + + return{ + //Setup registration + background: ({ state } : BgRuntime) =>{ + const { get, set } = useSingleSlotStorage(storage.local, 'pki-settings') + + const getPubKey = async (): Promise => { + const setting = await get() + + if (!setting?.privateKey) { + return undefined + } + + //Clone the private key, remove the private parts + const c = cloneDeep(setting.privateKey) + + delete c.d + delete c.p + delete c.q + delete c.dp + delete c.dq + delete c.qi + + return { + ...c, + userName: setting.userName + } as PkiPubKey + } + + return{ + regenerateKey: optionsOnly(async (userName:string, params:EcKeyParams) => { + const p = { + ...params, + name: "ECDSA", + } + + //Generate a new key + const key = await window.crypto.subtle.generateKey(p, true, ['sign', 'verify']) + + //Convert to jwk + const privateKey = await window.crypto.subtle.exportKey('jwk', key.privateKey) as JWK; + + //Convert to base64 so we can hash it easier + const b = btoa(privateKey.x! + privateKey.y!); + + //take sha256 of the binary version of the coords + const digest = await crypto.subtle.digest('SHA-256', Base64ToUint8Array(b)); + + //Set the kid + privateKey.kid = ArrayToHexString(digest); + + //Serial number is random hex + const serial = new Uint8Array(32) + crypto.getRandomValues(serial) + privateKey.serial = ArrayToHexString(serial); + + //Save the key + await set({ userName, privateKey }) + }), + getPubKey: optionsOnly(getPubKey), + generateOtp: optionsOnly(async () =>{ + const setting = await get() + if (!setting?.privateKey) { + throw new Error('No key found') + } + + const privKey = await importJWK(setting.privateKey as JWK) + + const random = new Uint8Array(32) + crypto.getRandomValues(random) + + const jwt = new SignJWT({ + 'sub': setting.userName, + 'n': ArrayToHexString(random), + keyid: setting.privateKey.kid, + serial: privKey.serial + }); + + const token = await jwt.setIssuedAt() + .setProtectedHeader({ alg: setting.privateKey.alg! }) + .setIssuer(state.currentConfig.value.apiUrl) + .setExpirationTime('30s') + .sign(privKey) + + return token + }) + } + }, + foreground: exportForegroundApi([ + 'regenerateKey', + 'getPubKey', + 'generateOtp' + ]) + } +} diff --git a/extension/src/features/auth-api.ts b/extension/src/features/auth-api.ts new file mode 100644 index 0000000..81bf6ea --- /dev/null +++ b/extension/src/features/auth-api.ts @@ -0,0 +1,123 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { AxiosInstance } from "axios"; +import { get, watchOnce } from "@vueuse/core"; +import { computed } from "vue"; +import { usePkiAuth, useSession, useUser } from "@vnuge/vnlib.browser"; +import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework"; +import type { ClientStatus } from "./types"; +import type { AppSettings } from "./settings"; +import type { JsonObject } from "type-fest"; + +export interface ProectedHandler { + (message: T): Promise +} + +export interface MessageHandler { + (message: T): Promise +} + +export interface ApiMessageHandler { + (message: T, apiHandle: { axios: AxiosInstance }): Promise +} + +export interface UserApi extends FeatureApi { + login: (token: string) => Promise + logout: () => Promise + getProfile: () => Promise + getStatus: () => Promise + waitForChange: () => Promise +} + +export const useAuthApi = (): IFeatureExport => { + + return { + background: ({ state, onInstalled }:BgRuntime): UserApi =>{ + const { loggedIn, clearLoginState } = useSession(); + const { currentConfig } = state + const { logout, getProfile, heartbeat, userName } = useUser(); + const currentPkiPath = computed(() => `${currentConfig.value.accountBasePath}/pki`) + + //Use pki login controls + const { login } = usePkiAuth(currentPkiPath as any) + + //We can send post messages to the server heartbeat endpoint to get status + const runHeartbeat = async () => { + //Only run if the api thinks its logged in, and config is enabled + if (!loggedIn.value || currentConfig.value.heartbeat !== true) { + return + } + + try { + //Post against the heartbeat endpoint + await heartbeat() + } + catch (error: any) { + if (error.response?.status === 401 || error.response?.status === 403) { + //If we get a 401, the user is no longer logged in + clearLoginState() + } + } + } + + //Install hook for interval + onInstalled(() => { + //Configure interval to run every 5 minutes to update the status + setInterval(runHeartbeat, 60 * 1000); + + //Run immediately + runHeartbeat(); + + return Promise.resolve(); + }) + + return { + login: popupOnly(async (token: string): Promise => { + //Perform login + await login(token) + //load profile + getProfile() + return true; + }), + logout: popupOnly(async (): Promise => { + //Perform logout + await logout() + //Cleanup after logout + clearLoginState() + }), + getProfile: popupAndOptionsOnly(getProfile), + async getStatus (){ + return { + //Logged in if the cookie is set and the api flag is set + loggedIn: get(loggedIn), + //username + userName: get(userName), + } as ClientStatus + }, + async waitForChange(){ + return new Promise((resolve) => watchOnce([currentConfig, loggedIn] as any, () => resolve())) + } + } + }, + foreground: exportForegroundApi([ + 'login', + 'logout', + 'getProfile', + 'getStatus', + 'waitForChange' + ]), + } +} \ No newline at end of file diff --git a/extension/src/features/framework/index.ts b/extension/src/features/framework/index.ts new file mode 100644 index 0000000..c58ca68 --- /dev/null +++ b/extension/src/features/framework/index.ts @@ -0,0 +1,214 @@ + +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { runtime } from "webextension-polyfill"; +import { createBackgroundPort } from '../../webext-bridge' +import { BridgeMessage, RuntimeContext, isInternalEndpoint } from "../../webext-bridge"; +import { serializeError, deserializeError } from 'serialize-error'; +import { JsonObject } from "type-fest"; +import { cloneDeep, isObjectLike, set } from "lodash"; +import { debugLog } from "@vnuge/vnlib.browser"; + +export interface BgRuntime { + readonly state: T; + onInstalled(callback: () => Promise): void; + onConnected(callback: () => Promise): void; +} + +export type FeatureApi = { + [key: string]: (... args: any[]) => Promise +}; + +export type SendMessageHandler = (action: string, data: any) => Promise +export type VarArgsFunction = (...args: any[]) => T +export type FeatureConstructor = () => IFeatureExport +export type DummyApiExport = Array + +export interface IFeatureExport { + /** + * Initializes a feature for mapping in the background runtime context + * @param bgRuntime The background runtime context + * @returns The feature's background api handlers that maps to the foreground + */ + background(bgRuntime: BgRuntime): TFeature + /** + * Initializes the feature for mapping in any foreground runtime context + * @returns The feature's foreground api stub methods for mapping. They must + * match the background api + */ + foreground(): TFeature +} + +export interface IForegroundUnwrapper { + /** + * Unwraps a foreground feature and builds it's method bindings to + * the background handler + * @param feature The foreground feature that will be mapped to it's + * background handlers + * @returns The foreground feature's api stub methods + */ + use: (feature: FeatureConstructor) => T +} + +export interface IBackgroundWrapper { + register(features: FeatureConstructor[]): void +} + +export interface ProtectedFunction extends Function { + readonly protection: RuntimeContext[] +} + +export const optionsOnly = (func: T): T => protectMethod(func, 'options'); +export const popupOnly = (func: T): T => protectMethod(func, 'popup'); +export const contentScriptOnly = (func: T): T => protectMethod(func, 'content-script'); +export const windowOnly = (func: T): T => protectMethod(func, 'window'); +export const popupAndOptionsOnly = (func: T): T => protectMethod(func, 'popup', 'options'); + +export const protectMethod = (func: T, ...protection: RuntimeContext[]): T => { + (func as any).protection = protection + return func; +} + +/** + * Creates a background runtime context for registering background + * script feature api handlers + */ +export const useBackgroundFeatures = (state: TState): IBackgroundWrapper => { + + const rt = { + state, + onConnected: runtime.onConnect.addListener, + onInstalled: runtime.onInstalled.addListener, + } as BgRuntime + + const { onMessage } = createBackgroundPort() + + /** + * Each plugin will export named methods. Background methods + * are captured and registered as on-message handlers that + * correspond to the method name. Foreground method calls + * are redirected to the send-message of the same unique name + */ + + return{ + register: (features: FeatureConstructor[]) => { + //Loop through features + for (const feature of features) { + + //Init feature + const f = feature().background(rt) + + //Get all exported function + for (const externFuncName in f) { + + //get exported function + const func = f[externFuncName] as Function + + const onMessageFuncName = `${feature.name}-${externFuncName}` + + //register method with api + onMessage(onMessageFuncName, async (msg: BridgeMessage) => { + try { + + //Always an internal endpoint + if (!isInternalEndpoint(msg.sender)) { + throw new Error(`Unauthorized external call to ${onMessageFuncName}`) + } + + if ((func as ProtectedFunction).protection + && !(func as ProtectedFunction).protection.includes(msg.sender.context)) { + throw new Error(`Unauthorized external call to ${onMessageFuncName}`) + } + const res = await func(...msg.data) + return isObjectLike(res) ? { ...res} : res + } + catch (e: any) { + debugLog(`Error in method ${onMessageFuncName}`, e) + const s = serializeError(e) + return { + bridgeMessageException: JSON.stringify(s), + axiosResponseError: JSON.stringify(e.response) + } + } + }); + } + } + } + } +} + +/** + * Creates a foreground runtime context for unwrapping foreground stub + * methods and redirecting them to thier background handler + */ +export const useForegoundFeatures = (sendMessage: SendMessageHandler): IForegroundUnwrapper => { + + /** + * The goal of this function is to get the foreground interface object + * that should match the background implementation. All methods are + * intercepted and redirected to the background via send-message + */ + + return{ + use: (feature:FeatureConstructor): T => { + //Register the feature + const api = feature().foreground() + const featureName = feature.name + const proxied : T = {} as T + + //Loop through all methods + for(const funcName in api){ + + //Create proxy for each method + set(proxied, funcName, async (...args:any) => { + + //Check for exceptions + const result = await sendMessage(`${featureName}-${funcName}`, cloneDeep(args)) as any + + if(result?.bridgeMessageException){ + const str = JSON.parse(result.bridgeMessageException) + const err = deserializeError(str) + //Recover axios response + if(result.axiosResponseError){ + (err as any).response = JSON.parse(result.axiosResponseError) + } + + throw err; + } + + return result; + }) + } + + return proxied; + } + } +} + +export const exportForegroundApi = (args: DummyApiExport): () => T => { + //Create the type from the array of type properties + const type = {} as T + + //Loop through all properties + for(const prop of args){ + //Default the property to an implementation error + type[prop] = (async () => { + throw new Error(`Method ${prop.toString()} not implemented`) + }) as any + } + + return () => type +} \ No newline at end of file diff --git a/extension/src/features/history.ts b/extension/src/features/history.ts new file mode 100644 index 0000000..ff7c267 --- /dev/null +++ b/extension/src/features/history.ts @@ -0,0 +1,42 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { ref } from "vue"; +import { } from "./types"; +import { FeatureApi, BgRuntime, IFeatureExport } from "./framework"; +import { AppSettings } from "./settings"; + +export interface HistoryEvent extends Object{ + +} + +export interface HistoryApi extends FeatureApi{ + +} + +export const useHistoryApi = () : IFeatureExport => { + return{ + background: ({ }: BgRuntime): HistoryApi =>{ + const evHistory = ref([]); + + return{ } + }, + foreground: (): HistoryApi =>{ + return { } + } + } +} + +//Listen for messages \ No newline at end of file diff --git a/extension/src/features/identity-api.ts b/extension/src/features/identity-api.ts new file mode 100644 index 0000000..07b6387 --- /dev/null +++ b/extension/src/features/identity-api.ts @@ -0,0 +1,112 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { Endpoints, useServerApi } from "./server-api"; +import { NostrPubKey, Watchable } from "./types"; +import { + type FeatureApi, + type IFeatureExport, + type BgRuntime, + optionsOnly, + popupAndOptionsOnly, + exportForegroundApi +} from "./framework"; +import { AppSettings } from "./settings"; +import { ref, watch } from "vue"; +import { useSession } from "@vnuge/vnlib.browser"; +import { get, set, useToggle, watchOnce } from "@vueuse/core"; +import { find, isArray } from "lodash"; + +export interface IdentityApi extends FeatureApi, Watchable { + createIdentity: (identity: NostrPubKey) => Promise + updateIdentity: (identity: NostrPubKey) => Promise + deleteIdentity: (key: NostrPubKey) => Promise + getAllKeys: () => Promise; + getPublicKey: () => Promise; + selectKey: (key: NostrPubKey) => Promise; +} + +export const useIdentityApi = (): IFeatureExport => { + return{ + background: ({ state }: BgRuntime) =>{ + const { execRequest } = useServerApi(state); + const { loggedIn } = useSession(); + + //Get the current selected key + const selectedKey = ref(); + const [ onTriggered , triggerChange ] = useToggle() + + //Clear the selected key if the user logs out + watch(loggedIn, (li) => li ? null : selectedKey.value = undefined) + + return { + //Identity is only available in options context + createIdentity: optionsOnly(async (id: NostrPubKey) => { + await execRequest(Endpoints.CreateId, id) + triggerChange() + }), + updateIdentity: optionsOnly(async (id: NostrPubKey) => { + await execRequest(Endpoints.UpdateId, id) + triggerChange() + }), + deleteIdentity: optionsOnly(async (key: NostrPubKey) => { + await execRequest(Endpoints.DeleteKey, key); + triggerChange() + }), + selectKey: popupAndOptionsOnly((key: NostrPubKey): Promise => { + selectedKey.value = key; + return Promise.resolve() + }), + getAllKeys: async (): Promise => { + if(!get(loggedIn)){ + return [] + } + //Get the keys from the server + const data = await execRequest(Endpoints.GetKeys); + + //Response must be an array of key objects + if (!isArray(data)) { + return []; + } + + //Make sure the selected keyid is in the list, otherwise unselect the key + if (data?.length > 0) { + if (!find(data, k => k.Id === selectedKey.value?.Id)) { + set(selectedKey, undefined); + } + } + + return [...data] + }, + getPublicKey: (): Promise => { + return Promise.resolve(selectedKey.value); + }, + waitForChange: () => { + console.log('Waiting for change') + return new Promise((resolve) => watchOnce([selectedKey, loggedIn, onTriggered] as any, () => resolve())) + } + } + }, + foreground: exportForegroundApi([ + 'createIdentity', + 'updateIdentity', + 'deleteIdentity', + 'getAllKeys', + 'getPublicKey', + 'selectKey', + 'waitForChange' + ]) + } +} \ No newline at end of file diff --git a/extension/src/features/index.ts b/extension/src/features/index.ts new file mode 100644 index 0000000..320ea1c --- /dev/null +++ b/extension/src/features/index.ts @@ -0,0 +1,33 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//Export all shared types +export type { NostrPubKey, LoginMessage } from './types' +export type * from './framework' +export type { PluginConfig } from './settings' +export type { PkiPubKey, EcKeyParams, LocalPkiApi as PkiApi } from './account-api' +export type { NostrApi } from './nostr-api' +export type { UserApi } from './auth-api' +export type { IdentityApi } from './identity-api' + +export { useBackgroundFeatures, useForegoundFeatures } from './framework' +export { useLocalPki, usePkiApi } from './account-api' +export { useAuthApi } from './auth-api' +export { useIdentityApi } from './identity-api' +export { useNostrApi } from './nostr-api' +export { useSettingsApi, useAppSettings } from './settings' +export { useHistoryApi } from './history' +export { useEventTagFilterApi } from './tagfilter-api' +export { useInjectAllowList } from './nip07allow-api' \ No newline at end of file diff --git a/extension/src/features/nip07allow-api.ts b/extension/src/features/nip07allow-api.ts new file mode 100644 index 0000000..eff4ff8 --- /dev/null +++ b/extension/src/features/nip07allow-api.ts @@ -0,0 +1,181 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { storage, tabs, type Tabs } from "webextension-polyfill"; +import { Watchable, useSingleSlotStorage } from "./types"; +import { defaultTo, filter, includes, isEqual } from "lodash"; +import { BgRuntime, FeatureApi, IFeatureExport, exportForegroundApi, popupAndOptionsOnly } from "./framework"; +import { AppSettings } from "./settings"; +import { set, get, watchOnce, useToggle } from "@vueuse/core"; +import { computed, ref } from "vue"; + +interface AllowedSites{ + origins: string[]; + enabled: boolean; +} +export interface AllowedOriginStatus{ + readonly allowedOrigins: string[]; + readonly enabled: boolean; + readonly currentOrigin?: string; + readonly isAllowed: boolean; +} + +export interface InjectAllowlistApi extends FeatureApi, Watchable { + addOrigin(origin?: string): Promise; + removeOrigin(origin?: string): Promise; + getStatus(): Promise; + enable(value: boolean): Promise; +} + +export const useInjectAllowList = (): IFeatureExport => { + return { + background: ({ }: BgRuntime) => { + + const store = useSingleSlotStorage(storage.local, 'nip07-allowlist', { origins: [], enabled: true }); + + //watch current tab + const allowedOrigins = ref([]) + const protectionEnabled = ref(true) + const [manullyTriggered, trigger] = useToggle() + + const { currentOrigin, currentTab } = (() => { + + const currentTab = ref(undefined) + const currentOrigin = computed(() => currentTab.value?.url ? new URL(currentTab.value.url).origin : undefined) + + //Watch for changes to the current tab + tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + //If the url changed, update the current tab + if (changeInfo.url) { + currentTab.value = tab + } + }) + + tabs.onActivated.addListener(async ({ tabId }) => { + //Get the tab + const tab = await tabs.get(tabId) + //Update the current tab + currentTab.value = tab + }) + return { currentTab, currentOrigin } + })() + + const writeChanges = async () => { + await store.set({ origins: [...get(allowedOrigins)], enabled: get(protectionEnabled) }) + } + + //Initial load + store.get().then((data) => { + allowedOrigins.value = data.origins + protectionEnabled.value = data.enabled + }) + + const isOriginAllowed = (origin?: string): boolean => { + //If protection is not enabled, allow all + if(protectionEnabled.value == false){ + return true; + } + //if no origin specified, use current origin + origin = defaultTo(origin, currentOrigin.value) + + //If no origin, return false + if (!origin) { + return false; + } + + //Default to origin only + const originOnly = new URL(origin).origin + return includes(allowedOrigins.value, originOnly) + } + + const addOrigin = async (origin?: string): Promise => { + //if no origin specified, use current origin + const newOrigin = defaultTo(origin, currentOrigin.value) + if (!newOrigin) { + return; + } + + const originOnly = new URL(newOrigin).origin + + //See if origin is already in the list + if (!includes(allowedOrigins.value, originOnly)) { + //Add to the list + allowedOrigins.value.push(originOnly); + trigger(); + + //Save changes + await writeChanges() + + //If current tab was added, reload the tab + if (!origin) { + await tabs.reload(currentTab.value?.id) + } + } + } + + const removeOrigin = async (origin?: string): Promise => { + //Allow undefined to remove current origin + const delOrigin = defaultTo(origin, currentOrigin.value) + if (!delOrigin) { + return; + } + + //Get origin part of url + const delOriginOnly = new URL(delOrigin).origin + const allowList = get(allowedOrigins) + + //Remove the origin + allowedOrigins.value = filter(allowList, (o) => !isEqual(o, delOriginOnly)); + trigger(); + + await writeChanges() + + //If current tab was removed, reload the tab + if (!origin) { + await tabs.reload(currentTab.value?.id) + } + } + + + return { + addOrigin: popupAndOptionsOnly(addOrigin), + removeOrigin: popupAndOptionsOnly(removeOrigin), + enable: popupAndOptionsOnly(async (value: boolean): Promise => { + set(protectionEnabled, value) + await writeChanges() + }), + async getStatus(): Promise { + return{ + allowedOrigins: [...get(allowedOrigins)], + enabled: get(protectionEnabled), + currentOrigin: get(currentOrigin), + isAllowed: isOriginAllowed() + } + }, + waitForChange: () => { + //Wait for the trigger to change + return new Promise((resolve) => watchOnce([currentTab, protectionEnabled, manullyTriggered] as any, () => resolve())); + }, + } + }, + foreground: exportForegroundApi([ + 'addOrigin', + 'removeOrigin', + 'getStatus', + 'enable', + 'waitForChange' + ]) + } +} \ No newline at end of file diff --git a/extension/src/features/nostr-api.ts b/extension/src/features/nostr-api.ts new file mode 100644 index 0000000..307522d --- /dev/null +++ b/extension/src/features/nostr-api.ts @@ -0,0 +1,79 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { Endpoints, useServerApi } from "./server-api"; +import { NostrRelay, EventMessage, NostrEvent } from './types' +import { FeatureApi, BgRuntime, IFeatureExport, optionsOnly, exportForegroundApi } from "./framework"; +import { AppSettings } from "./settings"; +import { useTagFilter } from "./tagfilter-api"; + + +/** + * The NostrApi is the foreground api for nostr events via + * the background script. + */ +export interface NostrApi extends FeatureApi { + getRelays: () => Promise; + signEvent: (event: NostrEvent) => Promise; + setRelay: (relay: NostrRelay) => Promise; + nip04Encrypt: (data: EventMessage) => Promise; + nip04Decrypt: (data: EventMessage) => Promise; +} + +export const useNostrApi = (): IFeatureExport => { + + return{ + background: ({ state }: BgRuntime) =>{ + + const { execRequest } = useServerApi(state); + const { filterTags } = useTagFilter() + + return { + getRelays: async (): Promise => { + //Get preferred relays for the current user + const data = await execRequest(Endpoints.GetRelays) + return [...data] + }, + signEvent: async (req: NostrEvent): Promise => { + + //If tag filter is enabled, filter before continuing + if(state.currentConfig.value.tagFilter){ + await filterTags(req) + } + + //Sign the event + const event = await execRequest(Endpoints.SignEvent, req); + return event; + }, + nip04Encrypt: async (data: EventMessage): Promise => { + return execRequest(Endpoints.Encrypt, data); + }, + nip04Decrypt: (data: EventMessage): Promise => { + return execRequest(Endpoints.Decrypt, data); + }, + setRelay: optionsOnly((relay: NostrRelay): Promise => { + return execRequest(Endpoints.SetRelay, relay) + }), + } + }, + foreground: exportForegroundApi([ + 'getRelays', + 'signEvent', + 'setRelay', + 'nip04Encrypt', + 'nip04Decrypt' + ]) + } +} diff --git a/extension/src/features/permissions.ts b/extension/src/features/permissions.ts new file mode 100644 index 0000000..c06257b --- /dev/null +++ b/extension/src/features/permissions.ts @@ -0,0 +1,80 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useStorageAsync } from "@vueuse/core"; +import { find, isEmpty, merge, remove } from "lodash"; +import { storage } from "webextension-polyfill"; +import { useAuthApi } from "./auth-api"; +import { useSettingsApi } from "./settings"; + +const permissions = useStorageAsync("permissions", [], storage.local); + +export const setAutoAllow = async (origin, mKind, keyId) => { + permissions.value.push({ origin, mKind, keyId, }) +} + +/** + * Determines if the user has previously allowed the origin to use the key to sign events + * of the desired kind + * @param {*} origin The site origin requesting the permission + * @param {*} mKind The kind of message being signed + * @param {*} keyId The keyId of the key being used to sign the message + */ +export const isAutoAllow = async (origin, mKind, keyId) => { + return find(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId) !== undefined +} + +/** + * Removes the auto allow permission from the list + * @param {*} origin The site origin requesting the permission + * @param {*} mKind The message kind being signed + * @param {*} keyId The keyId of the key being used to sign the message + */ +export const removeAutoAllow = async (origin, mKind, keyId) => { + //Remove the permission from the list + remove(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId); +} + + +export const useSitePermissions = (() => { + + const { apiCall, handleProtectedMessage } = useAuthApi(); + const { currentConfig } = useSettingsApi(); + + + const getCurrentPerms = async () => { + const { permissions } = await storage.local.get('permissions'); + + //Store a default config if none exists + if (isEmpty(permissions)) { + await storage.local.set({ siteConfig: defaultConfig }); + } + + //Merge the default config with the site config + return merge(defaultConfig, siteConfig) + } + + const onIsSiteEnabled = handleProtectedMessage(async (data) => { + + }) + + return () => { + return { + onCreateIdentity, + onUpdateIdentity + } + } + +})() \ No newline at end of file diff --git a/extension/src/features/server-api/endpoints.ts b/extension/src/features/server-api/endpoints.ts new file mode 100644 index 0000000..9c73866 --- /dev/null +++ b/extension/src/features/server-api/endpoints.ts @@ -0,0 +1,70 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { useAxios } from "@vnuge/vnlib.browser"; +import { Method } from "axios"; + +export interface EndpointDefinition { + readonly method: Method + path(...request: any): string + onRequest: (...request: any) => Promise + onResponse: (response: any, request?: any) => Promise +} + +export interface EndpointDefinitionReg extends EndpointDefinition { + readonly id: T +} + +export const initEndponts = () => { + + const endpoints = new Map(); + + //Get local axios + const axios = useAxios(null); + + const registerEndpoint = (def: EndpointDefinitionReg) => { + //Store the handler by its id + endpoints.set(def.id, def); + return def; + } + + const getEndpoint = (id: T): EndpointDefinition | undefined => endpoints.get(id); + + const execRequest = async (id: string, ...request: any): Promise => { + const endpoint = getEndpoint(id); + if (!endpoint) { + throw new Error(`Endpoint ${id} not found`); + } + + //Compute the path from the request + const path = endpoint.path(...request); + + //Execute the request handler + const req = await endpoint.onRequest(...request); + + //Exec the request + const { data } = await axios.request({ method: endpoint.method, url: path, data: req }); + + //exec the response handler and return its result + return await endpoint.onResponse(data, request); + } + + return { + registerEndpoint, + getEndpoint, + execRequest + } +} diff --git a/extension/src/features/server-api/index.ts b/extension/src/features/server-api/index.ts new file mode 100644 index 0000000..6aa34da --- /dev/null +++ b/extension/src/features/server-api/index.ts @@ -0,0 +1,137 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { computed } from "vue" +import { get } from '@vueuse/core' +import { type WebMessage, type UserProfile } from "@vnuge/vnlib.browser" +import { initEndponts } from "./endpoints" +import { type NostrIdentiy } from "../foreground/types" +import { cloneDeep } from "lodash" +import { type AppSettings } from "../settings" +import type { NostrEvent, NostrRelay } from "../types" + +export enum Endpoints { + GetKeys = 'getKeys', + DeleteKey = 'deleteKey', + SignEvent = 'signEvent', + GetRelays = 'getRelays', + SetRelay = 'setRelay', + Encrypt = 'encrypt', + Decrypt = 'decrypt', + CreateId = 'createIdentity', + UpdateId = 'updateIdentity', + UpdateProfile = 'updateProfile', +} + +export const useServerApi = (settings: AppSettings) => { + const { registerEndpoint, execRequest } = initEndponts() + + //ref to nostr endpoint url + const nostrUrl = computed(() => settings.currentConfig.value.nostrEndpoint || '/nostr'); + const accUrl = computed(() => settings.currentConfig.value.accountBasePath || '/account'); + + registerEndpoint({ + id: Endpoints.GetKeys, + method: 'GET', + path: () => `${get(nostrUrl)}?type=getKeys`, + onRequest: () => Promise.resolve(), + onResponse: (response) => Promise.resolve(response) + }) + + registerEndpoint({ + id: Endpoints.DeleteKey, + method: 'DELETE', + path: (key:NostrIdentiy) => `${get(nostrUrl)}?type=identity&key_id=${key.Id}`, + onRequest: () => Promise.resolve(), + onResponse: async (response: WebMessage) => response.getResultOrThrow() + }) + + registerEndpoint({ + id: Endpoints.SignEvent, + method: 'POST', + path: () => `${get(nostrUrl)}?type=signEvent`, + onRequest: (event) => Promise.resolve(event), + onResponse: async (response: WebMessage) => { + const res = response.getResultOrThrow() + delete (res as any).KeyId; + return res; + } + }) + + registerEndpoint({ + id: Endpoints.GetRelays, + method: 'GET', + path: () => `${get(nostrUrl)}?type=getRelays`, + onRequest: () => Promise.resolve(), + onResponse: (response) => Promise.resolve(response) + }) + + registerEndpoint({ + id: Endpoints.SetRelay, + method: 'POST', + path: () => `${get(nostrUrl)}?type=relay`, + onRequest: (relay:NostrRelay) => Promise.resolve(relay), + onResponse: (response) => Promise.resolve(response) + }) + + registerEndpoint({ + id: Endpoints.CreateId, + method: 'PUT', + path: () => `${get(nostrUrl)}?type=identity`, + onRequest: (identity:NostrIdentiy) => Promise.resolve(identity), + onResponse: async (response: WebMessage) => response.getResultOrThrow() + }) + + registerEndpoint({ + id: Endpoints.UpdateId, + method: 'PATCH', + path: () => `${get(nostrUrl)}?type=identity`, + onRequest: (identity:NostrIdentiy) => { + const id = cloneDeep(identity) as any; + delete id.Created; + delete id.LastModified; + return Promise.resolve(id) + }, + onResponse: async (response: WebMessage) => response.getResultOrThrow() + }) + + registerEndpoint({ + id: Endpoints.UpdateProfile, + method: 'POST', + path: () => `${get(accUrl)}`, + onRequest: (profile: UserProfile) => Promise.resolve(cloneDeep(profile)), + onResponse: async (response: WebMessage) => response.getResultOrThrow() + }) + + //Register nip04 events + registerEndpoint({ + id:Endpoints.Encrypt, + method:'POST', + path: () => `${get(nostrUrl)}?type=encrypt`, + onRequest: (data: NostrEvent) => Promise.resolve(data), + onResponse: async (response: WebMessage) => response.getResultOrThrow() + }) + + registerEndpoint({ + id:Endpoints.Decrypt, + method:'POST', + path: () => `${get(nostrUrl)}?type=decrypt`, + onRequest: (data: NostrEvent) => Promise.resolve(data), + onResponse: async (response: WebMessage) => response.getResultOrThrow() + }) + + return { execRequest } +} \ No newline at end of file diff --git a/extension/src/features/settings.ts b/extension/src/features/settings.ts new file mode 100644 index 0000000..a67957c --- /dev/null +++ b/extension/src/features/settings.ts @@ -0,0 +1,165 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { storage } from "webextension-polyfill" +import { isEmpty, merge } from 'lodash' +import { configureApi, debugLog } from '@vnuge/vnlib.browser' +import { readonly, ref, Ref } from "vue"; +import { JsonObject } from "type-fest"; +import { Watchable, useSingleSlotStorage } from "./types"; +import { BgRuntime, FeatureApi, optionsOnly, IFeatureExport, exportForegroundApi } from './framework' +import { get, watchOnce } from "@vueuse/core"; + +export interface PluginConfig extends JsonObject { + readonly apiUrl: string; + readonly accountBasePath: string; + readonly nostrEndpoint: string; + readonly heartbeat: boolean; + readonly maxHistory: number; + readonly tagFilter: boolean, +} + +//Default storage config +const defaultConfig : PluginConfig = { + apiUrl: import.meta.env.VITE_API_URL, + accountBasePath: import.meta.env.VITE_ACCOUNTS_BASE_PATH, + nostrEndpoint: import.meta.env.VITE_NOSTR_ENDPOINT, + heartbeat: import.meta.env.VITE_HEARTBEAT_ENABLED === 'true', + maxHistory: 50, + tagFilter: true, +}; + +export interface AppSettings{ + getCurrentConfig: () => Promise; + restoreApiSettings: () => Promise; + saveConfig: (config: PluginConfig) => Promise; + readonly currentConfig: Readonly>; +} + +export interface SettingsApi extends FeatureApi, Watchable { + getSiteConfig: () => Promise; + setSiteConfig: (config: PluginConfig) => Promise; + setDarkMode: (darkMode: boolean) => Promise; + getDarkMode: () => Promise; +} + +export const useAppSettings = (): AppSettings => { + const currentConfig = ref({} as PluginConfig); + const store = useSingleSlotStorage(storage.local, 'siteConfig', defaultConfig); + + const getCurrentConfig = async () => { + + const siteConfig = await store.get() + + //Store a default config if none exists + if (isEmpty(siteConfig)) { + await store.set(defaultConfig); + } + + //Merge the default config with the site config + return merge(defaultConfig, siteConfig) + } + + const restoreApiSettings = async () => { + //Set the current config + const current = await getCurrentConfig(); + currentConfig.value = current; + + //Configure the vnlib api + configureApi({ + session: { + cookiesEnabled: false, + browserIdSize: 32, + }, + user: { + accountBasePath: current.accountBasePath, + }, + axios: { + baseURL: current.apiUrl, + tokenHeader: import.meta.env.VITE_WEB_TOKEN_HEADER, + }, + storage: localStorage + }) + } + + const saveConfig = async (config: PluginConfig) => { + //Save the config and update the current config + await store.set(config); + currentConfig.value = config; + } + + return { + getCurrentConfig, + restoreApiSettings, + saveConfig, + currentConfig:readonly(currentConfig) + } +} + +export const useSettingsApi = () : IFeatureExport =>{ + + return{ + background: ({ state, onConnected, onInstalled }: BgRuntime) => { + + const _darkMode = ref(false); + + onInstalled(async () => { + await state.restoreApiSettings(); + debugLog('Server settings restored from storage'); + }) + + onConnected(async () => { + //refresh the config on connect + await state.restoreApiSettings(); + }) + + return { + + getSiteConfig: () => state.getCurrentConfig(), + + setSiteConfig: optionsOnly(async (config: PluginConfig): Promise => { + + //Save the config + await state.saveConfig(config); + + //Restore the api settings + await state.restoreApiSettings(); + + debugLog('Config settings saved!'); + + //Return the config + return state.currentConfig.value + }), + + setDarkMode: optionsOnly(async (darkMode: boolean) => { + console.log('Setting dark mode to', darkMode, 'from', _darkMode.value) + _darkMode.value = darkMode + }), + getDarkMode: async () => get(_darkMode), + + waitForChange: () => { + return new Promise((resolve) => watchOnce([state.currentConfig, _darkMode] as any, () => resolve())) + }, + } + }, + foreground: exportForegroundApi([ + 'getSiteConfig', + 'setSiteConfig', + 'setDarkMode', + 'getDarkMode', + 'waitForChange' + ]) + } +} \ No newline at end of file diff --git a/extension/src/features/tagfilter-api.ts b/extension/src/features/tagfilter-api.ts new file mode 100644 index 0000000..75f4b2a --- /dev/null +++ b/extension/src/features/tagfilter-api.ts @@ -0,0 +1,125 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { storage } from "webextension-polyfill"; +import { NostrEvent, TaggedNostrEvent, useSingleSlotStorage } from "./types"; +import { filter, isEmpty, isEqual, isRegExp } from "lodash"; +import { BgRuntime, FeatureApi, IFeatureExport, exportForegroundApi } from "./framework"; +import { AppSettings } from "./settings"; + +interface EventTagFilteStorage { + filters: string[]; +} + +export interface EventTagFilterApi extends FeatureApi { + filterTags(event: TaggedNostrEvent): Promise; + addFilter(tag: string): Promise; + removeFilter(tag: string): Promise; + addFilters(tags: string[]): Promise; +} + +export const useTagFilter = () => { + //use storage + const { get, set } = useSingleSlotStorage(storage.local, 'tag-filter-struct', { filters: [] }); + + return { + filterTags: async (event: TaggedNostrEvent): Promise => { + + if(!event.tags){ + return; + } + + //Load latest filter list + const data = await get(); + + if(isEmpty(event.tags)){ + return; + } + + /* + * Nostr events contain a nested array of tags, they may be any + * json type. The first element of the array should be the tag name + * and the rest of the array is the tag data. + */ + const allowedTags = filter(event.tags, ([tagName]) => { + if(!tagName){ + return false; + } + + if(!data.filters.length){ + return true; + } + + const asString = tagName.toString(); + + for (const filter of data.filters) { + //if the filter is a regex, test it, if it fails, its allowed + if (isRegExp(filter)) { + if (filter.test(asString)) { + return false; + } + } + //If the filter is a string, compare it, if it matches, it's not allowed + if (isEqual(filter, asString)) { + return false; + } + } + + //Its allowed + return true; + }) + + //overwrite tags array + event.tags = allowedTags; + }, + addFilter: async (tag: string) => { + const data = await get(); + //add new filter to list + data.filters.push(tag); + //save new config + await set(data); + }, + removeFilter: async (tag: string) => { + const data = await get(); + //remove filter from list + data.filters = filter(data.filters, (t) => !isEqual(t, tag)); + //save new config + await set(data); + }, + addFilters: async (tags: string[]) => { + const data = await get(); + //add new filters to list + data.filters.push(...tags); + //save new config + await set(data); + } + } +} + +export const useEventTagFilterApi = (): IFeatureExport => { + return{ + background: ({ }: BgRuntime) => { + return{ + ...useTagFilter() + } + }, + foreground: exportForegroundApi([ + 'filterTags', + 'addFilter', + 'removeFilter', + 'addFilters', + ]) + } +} \ No newline at end of file diff --git a/extension/src/features/types.ts b/extension/src/features/types.ts new file mode 100644 index 0000000..bd0afee --- /dev/null +++ b/extension/src/features/types.ts @@ -0,0 +1,147 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { defer } from "lodash"; +import { JsonObject } from "type-fest"; + +export interface NostrPubKey extends JsonObject { + readonly Id: string, + readonly UserName: string, + readonly PublicKey: string, + readonly Created: string, + readonly LastModified: string +} + +export interface NostrEvent extends JsonObject { + KeyId: string, + readonly id: string, + readonly pubkey: string, + readonly content: string, +} + +export interface TaggedNostrEvent extends NostrEvent { + tags?: any[][] +} + +export interface EventMessage extends JsonObject { + readonly event: NostrEvent +} + +export interface NostrRelay extends JsonObject { + readonly Id: string, + readonly url: string, + readonly flags: number, + readonly Created: string, + readonly LastModified: string +} + +export interface LoginMessage extends JsonObject { + readonly token: string +} + +export interface ClientStatus extends JsonObject { + readonly loggedIn: boolean; + readonly userName: string | null; +} + +export enum NostrRelayFlags { + None = 0, + Default = 1, + Preferred = 2, +} + +export enum NostrRelayMessageType{ + updateRelay = 1, + addRelay = 2, + deleteRelay = 3 +} + +export interface NostrRelayMessage extends JsonObject { + readonly relay: NostrRelay + readonly type: NostrRelayMessageType +} + +export interface LoginMessage extends JsonObject { + readonly token: string + readonly username: string + readonly password: string +} + +export interface Watchable{ + waitForChange(): Promise; +} + +export const useStorage = (storage: any & chrome.storage.StorageArea) => { + const get = async (key: string): Promise => { + const value = await storage.get(key) + return value[key] as T; + } + + const set = async (key: string, value: T): Promise => { + await storage.set({ [key]: value }); + } + + const remove = async (key: string): Promise => { + await storage.remove(key); + } + + return { get, set, remove } +} + +export interface SingleSlotStorage{ + get(): Promise; + set(value: T): Promise; + remove(): Promise; +} + +export interface DefaultSingleSlotStorage{ + get(): Promise; + set(value: T): Promise; + remove(): Promise; +} + +export interface UseSingleSlotStorage{ + (storage: any & chrome.storage.StorageArea, key: string): SingleSlotStorage; + (storage: any & chrome.storage.StorageArea, key: string, defaultValue: T): DefaultSingleSlotStorage; +} + +const _useSingleSlotStorage = (storage: any & chrome.storage.StorageArea, key: string, defaultValue?: T) => { + const s = useStorage(storage); + + const get = async (): Promise => { + return await s.get(key) || defaultValue; + } + + const set = (value: T): Promise => s.set(key, value); + const remove = (): Promise => s.remove(key); + + return { get, set, remove } +} + +export const useSingleSlotStorage: UseSingleSlotStorage = _useSingleSlotStorage; + +export const onWatchableChange = (watchable: Watchable, onChangeCallback: () => Promise, controls? : { immediate: boolean}) => { + + defer(async () => { + if (controls?.immediate) { + await onChangeCallback(); + } + + while (true) { + await watchable.waitForChange(); + await onChangeCallback(); + } + }) +} \ No newline at end of file diff --git a/extension/src/manifest.js b/extension/src/manifest.js index 19d51b1..6c96f41 100644 --- a/extension/src/manifest.js +++ b/extension/src/manifest.js @@ -19,7 +19,7 @@ import pkg from "../package.json"; const sharedManifest = { content_scripts: [ { - js: ["src/entries/contentScript/primary/main.js", "src/entries/contentScript/nostr-shim.js"], + js: ["src/entries/contentScript/primary/main.js"], matches: ["*://*/*"] }, ], @@ -37,7 +37,8 @@ const sharedManifest = { browser_style:false }, permissions: [ - 'storage' + 'storage', + 'activeTab', ], -- cgit