diff options
author | vnugent <public@vaughnnugent.com> | 2023-11-19 14:50:46 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2023-11-19 14:50:46 -0500 |
commit | bc7b86a242673d7831f6105d000995d9f4d63e09 (patch) | |
tree | 8da5c92047e92174b80ff6f460f8c3148e1e00ca /extension/src | |
parent | 0b609c17199e937518c42365b360288acfa872be (diff) |
hasty not working update to get my workspace clean
Diffstat (limited to 'extension/src')
48 files changed, 2219 insertions, 1421 deletions
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 <https://www.gnu.org/licenses/>. - - -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<ClientStatus>({ - 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<ClientStatus>('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<NostrPubKey>('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<Ref<{}>>} - */ -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<NostrPubKey[]> => { - //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<PluginConfig | undefined> => { - return await apiCall(async () => { - //Send the login request to the background script - return await sendMessage<PluginConfig>('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 <https://www.gnu.org/licenses/>. - - -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 <https://www.gnu.org/licenses/>. - - -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 <https://www.gnu.org/licenses/>. - -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 <https://www.gnu.org/licenses/>. - - -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 { - <T extends JsonObject>(action: string, data: any, context: string): Promise<T> -} - -export interface UseStatusResult { - toRefs: () => ToRefs<ClientStatus>, - update: () => Promise<void> -} - -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 <https://www.gnu.org/licenses/>. - -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<T extends JsonObject> { - (message: T): Promise<any> -} - -export interface MessageHandler<T extends JsonObject> { - (message: T): Promise<any> -} - -export interface ApiMessageHandler<T extends JsonObject> { - (message: T, apiHandle: { axios: AxiosInstance }): Promise<any> -} - - -export const useAuthApi = (() => { - - const { loggedIn } = useSession(); - const { clearLoginState } = useSessionUtils(); - const { logout, getProfile, heartbeat, userName } = useUser(); - const { currentConfig } = useSettings(); - - const apiCall = async <T>(asyncFunc: (h: ApiHandle) => Promise<T>): Promise<T> => { - 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 = <T extends JsonObject> (cbHandler: MessageHandler<T>) => { - return (message: BridgeMessage<T>): Promise<any> => { - return cbHandler(message.data) - } - } - - const handleProtectedMessage = <T extends JsonObject> (cbHandler: ProectedHandler<T>) => { - return (message: BridgeMessage<T>): Promise<any> => { - if (message.sender.context === 'options' || message.sender.context === 'popup') { - return cbHandler(message.data) - } - throw new Error('Unauthorized') - } - } - - const handleApiCall = <T extends JsonObject>(cbHandler: ApiMessageHandler<T>) => { - return (message: BridgeMessage<T>): Promise<any> => { - return apiCall((m) => cbHandler(message.data, m)) - } - } - - const handleProtectedApicall = <T extends JsonObject>(cbHandler: ApiMessageHandler<T>) => { - return (message: BridgeMessage<T>): Promise<any> => { - 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<any> => { - //Perform login - const { login } = usePkiAuth(`${currentConfig.value.accountBasePath}/pki`); - await login(data.token) - return true; - }) - - const onLogout = handleProtectedApicall(async () : Promise<void> => { - await logout() - //Cleanup after logout - clearLoginState() - }) - - const onGetProfile = handleProtectedApicall(() : Promise<any> => getProfile()) - - const onGetStatus = async (): Promise<ClientStatus> => { - 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 <https://www.gnu.org/licenses/>. - -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 <https://www.gnu.org/licenses/>. - -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<NostrIdentiy>(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 <https://www.gnu.org/licenses/>. -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<HistoryMessage>('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 <https://www.gnu.org/licenses/>. - -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<NostrPubKey | null>({} as NostrPubKey) - - //Selected key is allowed from content script - const onGetPubKey = () => ({ ...selectedKey.value }); - - const onDeleteKey = handleProtectedApicall<NostrPubKey>(data => execRequest<NostrPubKey>(Endpoints.DeleteKey, data)) - - //Set the selected key to the value - const onSelectKey = handleProtectedMessage<NostrPubKey>(data => (selectedKey.value = data, Promise.resolve())); - - const onGetAllKeys = handleProtectedApicall(async () => { - //Get the keys from the server - const data = await execRequest<NostrPubKey[]>(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<EventMessage>(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<NostrEvent>(Endpoints.SignEvent, data.event); - return { event }; - }) - - const onGetRelays = handleApiCall<any>(async () => { - //Get preferred relays for the current user - const data = await execRequest<NostrRelay[]>(Endpoints.GetRelays) - return [...data] - }) - - const onSetRelay = handleProtectedApicall<NostrRelay>(data => execRequest<NostrRelay>(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/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 <https://www.gnu.org/licenses/>. - - -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<string>) => { - 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<NostrEvent>) => { - 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 <https://www.gnu.org/licenses/>. - -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<PluginConfig>({} 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<void> => { - await storage.local.set({ siteConfig: config }); - } - - const onGetSiteConfig = async ({ } :BridgeMessage<any>): Promise<PluginConfig> => { - return { ...currentConfig.value } - } - - const onSetSitConfig = async ({ sender, data }: BridgeMessage<PluginConfig>) : Promise<void> => { - //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 <https://www.gnu.org/licenses/>. - -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 <https://www.gnu.org/licenses/>. - -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: </div> <div class="p-2 mt-1 text-center border rounded border-dark-400 bg-dark-600"> - <div :class="[selectedKey?.UserName ? '' : 'text-red-500']"> - {{ selectedKey?.UserName ?? 'Select Identity' }} + <div :class="[keyName ? '' : 'text-red-500']"> + {{ keyName ?? 'Select Identity' }} </div> </div> <div class="mt-5 text-center"> @@ -65,14 +65,17 @@ <script setup lang="ts"> import { ref } from 'vue' -import { usePrompt } from '~/entries/contentScript/nostr-shim' +import { usePrompt } from '../../nostr-shim.js' import { computed } from '@vue/reactivity'; -import { onClickOutside } from '@vueuse/core'; -import { useStatus } from '~/bg-api/content-script.ts'; +import { } from '@vueuse/core'; import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue' import { first } from 'lodash'; +import { useStore } from '../../../store'; +import { storeToRefs } from 'pinia'; -const { loggedIn, selectedKey } = useStatus() +const store = useStore() +const { loggedIn, selectedKey } = storeToRefs(store) +const keyName = computed(() => selectedKey.value?.UserName) const prompt = ref(null) @@ -110,7 +113,7 @@ const allow = () => { //Listen for events usePrompt(async (ev: PopupEvent) => { - console.log('usePrompt', ev) + console.log('[usePrompt] =>', ev) switch(ev.type){ case 'getPublicKey': @@ -130,7 +133,7 @@ usePrompt(async (ev: PopupEvent) => { break; } - return new Promise((resolve, reject) => { + return new Promise((resolve) => { evStack.value.push({ ...ev, allow: () => resolve(true), diff --git a/extension/src/entries/contentScript/primary/main.js b/extension/src/entries/contentScript/primary/main.js index 047d0e9..bbf0932 100644 --- a/extension/src/entries/contentScript/primary/main.js +++ b/extension/src/entries/contentScript/primary/main.js @@ -15,6 +15,8 @@ import { createApp } from "vue"; +import { createPinia } from 'pinia'; +import { useBackgroundPiniaPlugin, identityPlugin, originPlugin } from '../../store' import renderContent from "../renderContent"; import App from "./App.vue"; import Notification from '@kyvg/vue3-notification' @@ -23,12 +25,27 @@ import '@fontsource/noto-sans-masaram-gondi' //We need inline styles to inject into the shadow dom import tw from "~/assets/all.scss?inline"; import localStyle from './style.scss?inline' +import { onLoad } from "../nostr-shim"; +import { defer } from "lodash"; renderContent([], (appRoot, shadowRoot) => { + + //Create the background feature wiring + const bgPlugins = useBackgroundPiniaPlugin('content-script') + //Init store and add plugins + const store = createPinia() + .use(bgPlugins) + .use(identityPlugin) + .use(originPlugin) + createApp(App) + .use(store) .use(Notification) .mount(appRoot); + //Load the nostr shim + defer(onLoad) + //Add tailwind styles just to the shadow dom element const style = document.createElement('style') style.innerHTML = tw.toString() diff --git a/extension/src/entries/options/App.vue b/extension/src/entries/options/App.vue index f55b73b..dd71209 100644 --- a/extension/src/entries/options/App.vue +++ b/extension/src/entries/options/App.vue @@ -53,7 +53,7 @@ </TabList> <TabPanels> <TabPanel class="mt-4"> - <Identities :all-keys="allKeys" @edit-key="editKey" @update-all="reloadKeys"/> + <Identities :all-keys="allKeys" @edit-key="editKey"/> </TabPanel> <TabPanel> <Privacy/> @@ -107,23 +107,24 @@ import { TabPanels, TabPanel, } from '@headlessui/vue' -import { configureNotifier } from '@vnuge/vnlib.browser'; -import { useManagment, useStatus, NostrPubKey } from '~/bg-api/options.ts'; +import { apiCall, configureNotifier } from '@vnuge/vnlib.browser'; +import { storeToRefs } from "pinia"; +import { type NostrPubKey } from '../../features/'; import { notify } from "@kyvg/vue3-notification"; -import { watchDebounced } from '@vueuse/core'; import SiteSettings from './components/SiteSettings.vue'; import Identities from './components/Identities.vue'; import Privacy from "./components/Privacy.vue"; +import { useStore } from "../store"; + //Configure the notifier to use the notification library configureNotifier({ notify, close: notify.close }) -const { userName, darkMode } = useStatus() -const { getAllKeys, updateIdentity, getSiteConfig, saveSiteConfig } = useManagment() +const store = useStore() +const { allKeys, darkMode, userName } = storeToRefs(store) const selectedTab = ref(0) -const allKeys = ref([]) -const keyBuffer = ref(null) +const keyBuffer = ref<NostrPubKey>({} as NostrPubKey) const editKey = (key: NostrPubKey) =>{ //Goto hidden tab @@ -140,31 +141,24 @@ const doneEditing = () =>{ } const onUpdate = async () =>{ - //Update identity - await updateIdentity(keyBuffer.value) + + await apiCall(async ({ toaster }) => { + //Update identity + await store.updateIdentity(keyBuffer.value) + //Show success + toaster.general.success({ + 'title':'Success', + 'text': `Successfully updated ${keyBuffer.value!.UserName}` + }) + }) + //Goto hidden tab selectedTab.value = 0 //Set selected key keyBuffer.value = null } -const reloadKeys = async () =>{ - //Load all keys (identities) - const keys = await getAllKeys() - allKeys.value = keys; -} - -const toggleDark = async () => { - const config = await getSiteConfig(); - config.darkMode = !config.darkMode; - await saveSiteConfig(config); -} - -//Initial load -reloadKeys(); - -//If the tab changes to the identities tab, reload the keys -watchDebounced(selectedTab, id => id == 0 ? reloadKeys() : null, { debounce: 100 }) +const toggleDark = () => store.toggleDarkMode() //Watch for dark mode changes and update the body class watchEffect(() => darkMode.value ? document.body.classList.add('dark') : document.body.classList.remove('dark')); diff --git a/extension/src/entries/options/components/Identities.vue b/extension/src/entries/options/components/Identities.vue index d7fd75d..e3af74e 100644 --- a/extension/src/entries/options/components/Identities.vue +++ b/extension/src/entries/options/components/Identities.vue @@ -39,7 +39,7 @@ </Popover> </div> </div> - <div v-for="key in allKeys" :key="key" class="mt-2 mb-3"> + <div v-for="key in allKeys" :key="key.Id" class="mt-2 mb-3"> <div class="" :class="{'selected': isSelected(key)}" @click.self="selectKey(key)"> <div class="mb-8"> @@ -83,7 +83,7 @@ <script setup lang="ts"> import { isEqual, map } from 'lodash' -import { ref, toRefs } from "vue"; +import { ref } from "vue"; import { Popover, PopoverButton, @@ -91,30 +91,26 @@ import { PopoverOverlay } from '@headlessui/vue' import { apiCall, configureNotifier } from '@vnuge/vnlib.browser'; -import { useManagment, useStatus } from '~/bg-api/options.ts'; +import { NostrPubKey } from '../../../features'; import { notify } from "@kyvg/vue3-notification"; -import { useClipboard } from '@vueuse/core'; -import { NostrIdentiy } from '~/bg-api/bg-api'; -import { NostrPubKey } from '../../background/types'; +import { get, useClipboard } from '@vueuse/core'; +import { useStore } from '../../store'; +import { storeToRefs } from 'pinia'; -const emit = defineEmits(['edit-key', 'update-all']) -const props = defineProps<{ - allKeys:NostrIdentiy[] -}>() - -const { allKeys } = toRefs(props) +const emit = defineEmits(['edit-key']) //Configre the notifier to use the toaster configureNotifier({ notify, close: notify.close }) const downloadAnchor = ref<HTMLAnchorElement>() -const { selectedKey } = useStatus() -const { selectKey, createIdentity, deleteIdentity, getAllKeys } = useManagment() +const store = useStore() +const { selectedKey, allKeys } = storeToRefs(store) const { copy } = useClipboard() -const isSelected = (me : NostrIdentiy) => isEqual(me, selectedKey.value) -const editKey = (key : NostrIdentiy) => emit('edit-key', key); +const isSelected = (me : NostrPubKey) => isEqual(me, selectedKey.value) +const editKey = (key : NostrPubKey) => emit('edit-key', key); +const selectKey = (key: NostrPubKey) => store.selectKey(key) const onCreate = async (e: Event, onClose : () => void) => { @@ -123,35 +119,39 @@ const onCreate = async (e: Event, onClose : () => void) => { //try to get existing key field const ExistingKey = e.target['key']?.value as string - //Create new identity - await createIdentity({ UserName, ExistingKey }) - //Update keys - emit('update-all'); + await apiCall(async () => { + //Create new identity + await store.createIdentity({ UserName, ExistingKey }) + }) + onClose() } -const prettyPrintDate = (key : NostrIdentiy) => { +const prettyPrintDate = (key : NostrPubKey) => { const d = new Date(key.LastModified) return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}` } -const onDeleteKey = async (key : NostrIdentiy) => { +const onDeleteKey = async (key : NostrPubKey) => { if(!confirm(`Are you sure you want to delete ${key.UserName}?`)){ return; } - //Delete identity - await deleteIdentity(key) - - //Update keys - emit('update-all'); + apiCall(async ({ toaster }) => { + //Delete identity + await store.deleteIdentity(key) + toaster.general.success({ + 'title': 'Success', + 'text': `${key.UserName} has been deleted` + }) + }) } const onNip05Download = () => { apiCall(async () => { //Get all public keys from the server - const keys = await getAllKeys() as NostrPubKey[] + const keys = get(allKeys) const nip05 = {} //Map the keys to the NIP-05 format map(keys, k => nip05[k.UserName] = k.PublicKey) diff --git a/extension/src/entries/options/components/Privacy.vue b/extension/src/entries/options/components/Privacy.vue index 7d2ce4d..d54f679 100644 --- a/extension/src/entries/options/components/Privacy.vue +++ b/extension/src/entries/options/components/Privacy.vue @@ -1,9 +1,68 @@ <template> <div class="flex flex-col w-full mt-4 sm:px-2"> - + <div class="flex flex-row gap-1"> + <div class="text-2xl"> + Tracking protection + </div> + <div class="mt-auto" :class="[isOriginProtectionOn ? 'text-primary-600' : 'text-red-500']"> + {{ isOriginProtectionOn ? 'active' : 'inactive' }} + </div> + </div> + <div class=""> + <div class="p-2"> + <div class="my-1"> + <form @submit.prevent="allowOrigin()"> + <input class="w-full max-w-xs input primary" type="text" v-model="newOrigin" placeholder="Add new origin"/> + <button type="submit" class="ml-1 btn xs" > + <fa-icon icon="plus" /> + </button> + </form> + </div> + <label class="font-semibold">Whitelist:</label> + <ul class="pl-1 list-disc list-inside"> + <li v-for="origin in allowedOrigins" :key="origin" class="my-1 text-sm"> + <span class=""> + {{ origin }} + </span> + <span> + <button class="ml-1 text-xs text-red-500" @click="store.dissallowOrigin(origin)"> + remove + </button> + </span> + </li> + </ul> + </div> + </div> </div> </template> <script setup lang="ts"> +import { storeToRefs } from 'pinia'; +import { useStore } from '../../store'; +import { useFormToaster } from '@vnuge/vnlib.browser'; +import { ref } from 'vue'; + +const store = useStore() +const { isOriginProtectionOn, allowedOrigins } = storeToRefs(store) +const newOrigin = ref('') +const { error, info } = useFormToaster() + +const allowOrigin = async () =>{ + try { + await store.allowOrigin(newOrigin.value) + } + catch (err: any) { + error({ + title: 'Failed to allow origin', + text: err.message + }) + return; + } + info({ + title: 'Origin allowed', + text: `Origin ${newOrigin.value} has been allowed` + }) + newOrigin.value = '' +} </script>
\ 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 </h3> <div class="my-6"> - <fieldset :disabled="waiting"> + <fieldset :disabled="waiting.value"> <div class=""> <div class="w-full"> <div class="flex flex-row w-full"> <Switch - v-model="buffer.autoInject" - :class="buffer.autoInject ? 'bg-black dark:bg-gray-50' : 'bg-gray-200 dark:bg-dark-600'" + v-model="originProtection" + :class="originProtection ? 'bg-black dark:bg-gray-50' : 'bg-gray-200 dark:bg-dark-600'" class="relative inline-flex items-center h-5 rounded-full w-11" > - <span class="sr-only">NIP-07</span> + <span class="sr-only">Origin protection</span> <span - :class="buffer.autoInject ? 'translate-x-6' : 'translate-x-1'" + :class="originProtection ? 'translate-x-6' : 'translate-x-1'" class="inline-block w-4 h-4 transition transform bg-white rounded-full dark:bg-dark-900" /> </Switch> <div class="my-auto ml-2 text-sm dark:text-gray-200"> - Always on NIP-07 + Tracking protection </div> </div> </div> @@ -69,27 +69,42 @@ </a> </div> </div> - <fieldset :disabled="waiting || !editMode"> + <fieldset> <div class="pl-1 mt-2"> </div> <div class="mt-2"> <label class="pl-1">BaseUrl</label> - <input class="w-full input" v-model="v$.apiUrl.$model" :class="{'error': v$.apiUrl.$invalid }" /> + <input + class="w-full input" + :class="{'error': v$.apiUrl.$invalid }" + v-model="v$.apiUrl.$model" + :readonly="!editMode" + /> <p class="pl-1 mt-1 text-xs text-gray-600 dark:text-gray-400"> * The http path to the vault server (must start with http:// or https://) </p> </div> <div class="mt-2"> <label class="pl-1">Account endpoint</label> - <input class="w-full input" v-model="v$.accountBasePath.$model" :class="{ 'error': v$.accountBasePath.$invalid }" /> + <input + class="w-full input" + v-model="v$.accountBasePath.$model" + :class="{ 'error': v$.accountBasePath.$invalid }" + :readonly="!editMode" + /> <p class="pl-1 mt-1 text-xs text-gray-600 dark:text-gray-400"> * This is the path to the account server endpoint (must start with /) </p> </div> <div class="mt-2"> <label class="pl-1">Nostr endpoint</label> - <input class="w-full input" v-model="v$.nostrEndpoint.$model" :class="{ 'error': v$.nostrEndpoint.$invalid }" /> + <input + class="w-full input" + v-model="v$.nostrEndpoint.$model" + :class="{ 'error': v$.nostrEndpoint.$invalid }" + :readonly="!editMode" + /> <p class="pl-1 mt-1 text-xs text-gray-600 dark:text-gray-400"> * This is the path to the Nostr plugin endpoint path (must start with /) </p> @@ -104,25 +119,31 @@ </template> <script setup lang="ts"> -import { apiCall, useDataBuffer, useFormToaster, useVuelidateWrapper, useWait } from '@vnuge/vnlib.browser'; -import { computed, ref, watch } from 'vue'; -import { useManagment } from '~/bg-api/options.ts'; +import { apiCall, useDataBuffer, useVuelidateWrapper, useWait } from '@vnuge/vnlib.browser'; +import { computed, watch } from 'vue'; import { useToggle, watchDebounced } from '@vueuse/core'; import { maxLength, helpers, required } from '@vuelidate/validators' -import { clone, isNil } from 'lodash'; -import{ Switch } from '@headlessui/vue' +import { Switch } from '@headlessui/vue' +import { useStore } from '../../store'; +import { storeToRefs } from 'pinia'; import useVuelidate from '@vuelidate/core' const { waiting } = useWait(); -const { info } = useFormToaster(); -const { getSiteConfig, saveSiteConfig } = useManagment(); - -const { apply, data, buffer, modified } = useDataBuffer({ - apiUrl: '', - accountBasePath: '', - nostrEndpoint:'', - heartbeat:false, - autoInject:true, +const store = useStore() +const { settings, isOriginProtectionOn } = storeToRefs(store) + +const { apply, data, buffer, modified, update } = useDataBuffer(settings.value, async sb =>{ + const newConfig = await store.saveSiteConfig(sb.buffer) + apply(newConfig) + return newConfig; +}) + +//Watch for store settings changes and apply them +watch(settings, v => apply(v.value)) + +const originProtection = computed({ + get: () => isOriginProtectionOn.value, + set: v => store.setOriginProtection(v) }) const url = (val : string) => /^https?:\/\/[a-zA-Z0-9\.\:\/-]+$/.test(val); @@ -145,15 +166,13 @@ const vRules = { alphaNum: helpers.withMessage('Nostr path is not a valid endpoint path that begins with /', path) }, heartbeat: {}, - darkMode:{} } //Configure validator and validate function const v$ = useVuelidate(vRules, buffer) -const { validate } = useVuelidateWrapper(v$); +const { validate } = useVuelidateWrapper(v$ as any); -const editMode = ref(false); -const toggleEdit = useToggle(editMode); +const [ editMode, toggleEdit ] = useToggle(false); const autoInject = computed(() => buffer.autoInject) const heartbeat = computed(() => buffer.heartbeat) @@ -171,24 +190,12 @@ const onSave = async () => { return; } - info({ - title: 'Reloading in 4 seconds', - text: 'Your configuration will be saved and the extension will reload in 4 seconds' - }) - - await new Promise(r => setTimeout(r, 4000)); - - publishConfig(); + await update(); //disable dit toggleEdit(); } -const publishConfig = async () =>{ - const c = clone(buffer); - await saveSiteConfig(c); - await loadConfig(); -} const testConnection = async () =>{ return await apiCall(async ({axios, toaster}) =>{ @@ -201,38 +208,17 @@ const testConnection = async () =>{ return true; } catch(e){ - if(isNil(e.response?.status)){ - toaster.form.error({ - title: 'Network error', - text: `Please verify your vault server address` - }); - } - toaster.form.error({ title: 'Warning', - text: `Failed to connect to the vault server. Status code: ${e.response.status}` + text: `Failed to connect to the vault server. Status code: ${(e as any).response?.status}` }); } }) } -const loadConfig = async () => { - const config = await getSiteConfig(); - apply(config); -} - -const init = async () => { - await loadConfig(); - - //Watch for changes to autoinject value and publish changes when it does - watchDebounced(autoInject, publishConfig, { debounce: 500, immediate: false }) - watchDebounced(heartbeat, publishConfig, { debounce: 500, immediate: false }) -} - -//If edit mode is toggled off, reload config -watch(editMode, v => v ? null : loadConfig()); - -init(); +//Watch for changes to autoinject value and publish changes when it does +watchDebounced(autoInject, update, { debounce: 500, immediate: false }) +watchDebounced(heartbeat, update, { debounce: 500, immediate: false }) </script> 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; } </style> 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 @@ <template> - <div class="px-3 text-left"> + <div class="text-left"> <div class="w-full"> <div class=""> <select class="w-full input" @@ -19,28 +19,22 @@ <script setup lang="ts"> import { find } from 'lodash' import { computed } from "vue"; -import { useStatus, useManagment, NostrPubKey } from "~/bg-api/popup.ts"; +import { useStore } from "../../store"; import { useWait } from '@vnuge/vnlib.browser' -import { computedAsync } from '@vueuse/core'; +import { storeToRefs } from 'pinia'; -const { selectedKey } = useStatus(); const { waiting } = useWait(); -const { getAllKeys, selectKey } = useManagment(); - -const allKeys = computedAsync<NostrPubKey[]>(async () => await getAllKeys(), []); +const store = useStore(); +const { selectedKey, allKeys } = storeToRefs(store); const onSelected = async ({target}) =>{ //Select the key of the given id const selected = find(allKeys.value, {Id: target.value}) if(selected){ - await selectKey(selected) + await store.selectKey(selected) } } -const selected = computed(() => selectedKey?.value || { Id:"0" }) +const selected = computed(() => selectedKey?.value || { Id:"" }) </script> - -<style lang="scss"> - -</style>
\ No newline at end of file diff --git a/extension/src/entries/popup/Components/Login.vue b/extension/src/entries/popup/Components/Login.vue index c863430..44df714 100644 --- a/extension/src/entries/popup/Components/Login.vue +++ b/extension/src/entries/popup/Components/Login.vue @@ -19,18 +19,24 @@ </template> <script setup lang="ts"> -import { useWait } from "@vnuge/vnlib.browser"; +import { apiCall, useWait } from "@vnuge/vnlib.browser"; import { ref } from "vue"; -import { useManagment } from "~/bg-api/popup.ts"; +import { useStore } from "../../store"; -const { login } = useManagment() +const { login } = useStore() const { waiting } = useWait() const token = ref('') const onSubmit = async () => { - //console.log(token.value) - await login(token.value) + await apiCall(async ({ toaster }) => { + await login(token.value) + toaster.general.success({ + 'title': 'Login successful', + 'text': 'Successfully logged into your profile' + }) + }) + } </script> 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]" > - <div class="flex flex-row w-full px-1 pl-4"> - <div class="flex-auto my-auto"> - <h3>NVault</h3> + <div class="flex flex-row w-full gap-2 p-1.5 bg-black text-white dark:bg-dark-600 shadow"> + <div class="flex flex-row flex-auto my-auto"> + <div class="my-auto mr-2"> + <img class="h-6" src="/icons/32.png" /> + </div> + <h3 class="block my-auto">NVault</h3> + <div class="px-3 py-.5 m-auto text-sm rounded-full h-fit active-badge" :class="[isTabAllowed ? 'active' : 'inactive']"> + {{ isTabAllowed ? 'Active' : 'Inactive' }} + </div> </div> <div class="my-auto" v-if="loggedIn"> - <button class="rounded btn sm red" @click.prevent="logout"> + <button class="rounded btn xs" @click.prevent="logout"> <fa-icon icon="arrow-right-from-bracket" /> </button> </div> - <div class="p-2 my-auto"> - <button class="rounded btn sm" @click="openOptions"> + <div class="my-auto"> + <button class="rounded btn xs" @click="openOptions"> <fa-icon :icon="['fas', 'gear']"/> </button> </div> </div> + <div v-if="!loggedIn"> <Login></Login> </div> - <div v-else class="flex justify-center pb-4"> - <div class="w-full m-auto"> - <div class="mt-2 text-center"> - {{ userName }} - <div class="mt-4"> + + <div v-else class="flex justify-center"> + <div class="w-full px-3 m-auto"> + + <div class="text-sm text-center"> + {{ userName }} + </div> + + <div class=""> + <label class="mb-0.5 text-sm dark:text-dark-100"> + Identity + </label> <IdentitySelection></IdentitySelection> </div> - <div class="mt-2.5 min-h-[6rem]"> - <div class="flex flex-col justify-center"> - - <div class="flex flex-row gap-2 p-2 mx-3 my-3 bg-gray-100 border border-gray-200 rounded dark:bg-dark-700 dark:border-dark-400"> + + <div class="w-full mt-1"> + <div class="flex flex-col"> + <div class="flex flex-row gap-2 p-1.5 bg-gray-100 border border-gray-200 dark:bg-dark-800 dark:border-dark-400"> <div class="text-sm break-all"> {{ pubKey ?? 'No key selected' }} </div> @@ -40,13 +54,30 @@ <fa-icon class="mr-1" icon="copy" @click="copy(pubKey)"/> </div> </div> - </div> </div> - <div class="mt-3 text-sm"> - Always on NIP-07: <span class="font-semibold" :class="{'text-blue-500':autoInject}">{{ autoInject }}</span> + + <div class="mt-4"> + <label class="block mb-1 text-xs text-left dark:text-dark-100" > + Current origin + </label> + + <div v-if="isOriginProtectionOn" class="flex flex-row w-full gap-2"> + <input :value="currentOrigin" class="flex-1 p-1 mx-0 text-sm input" readonly/> + + <button v-if="isTabAllowed" class="btn xs" @click="store.dissallowOrigin()"> + <fa-icon icon="minus" /> + </button> + <button v-else class="btn xs" @click="store.allowOrigin()"> + <fa-icon icon="plus" /> + </button> + </div> + + <div v-else class="text-xs text-center"> + <span class="">Tracking protection disabled</span> + </div> </div> - </div> + </div> </div> @@ -57,9 +88,10 @@ <script setup lang="ts"> import { computed, watchEffect } from "vue"; -import { useStatus, useManagment } from "~/bg-api/popup.ts"; -import { configureNotifier } from "@vnuge/vnlib.browser"; -import { asyncComputed, useClipboard, watchDebounced } from '@vueuse/core' +import { storeToRefs } from "pinia"; +import { useStore } from "../../store"; +import { apiCall, configureNotifier } from "@vnuge/vnlib.browser"; +import { useClipboard } from '@vueuse/core' import { notify } from "@kyvg/vue3-notification"; import { runtime } from "webextension-polyfill"; import Login from "./Login.vue"; @@ -67,38 +99,25 @@ import IdentitySelection from "./IdentitySelection.vue"; configureNotifier({notify, close:notify.close}) -const { loggedIn, userName, selectedKey, darkMode } = useStatus() -const { logout, getProfile, getSiteConfig } = useManagment() - +const store = useStore() +const { loggedIn, selectedKey, userName, darkMode, isTabAllowed, currentOrigin, isOriginProtectionOn } = storeToRefs(store) const { copy, copied } = useClipboard() -const pubKey = computed(() => selectedKey.value?.PublicKey) -const qrCode = computed(() => pubKey.value ? `nostr:npub1${pubKey.value}` : null) - -watchDebounced(loggedIn, async () => { - //Manually update the user's profile if they are logged in and the profile is not yet loaded - if(loggedIn.value && !userName.value){ - getProfile() - } -},{ debounce:100, immediate: true }) +const pubKey = computed(() => selectedKey!.value?.PublicKey) const openOptions = () => runtime.openOptionsPage(); //Watch for dark mode changes and update the body class watchEffect(() => darkMode.value ? document.body.classList.add('dark') : document.body.classList.remove('dark')); -const autoInject = asyncComputed(() => getSiteConfig().then<Boolean>(p => p.autoInject), false) - -</script> - -<style lang="scss"> - -.toaster{ - position: fixed; - top: 15px; - right: 0; - z-index: 9999; - max-width: 230px; +const logout = () =>{ + apiCall(async ({ toaster }) =>{ + await store.logout() + toaster.general.success({ + 'title':'Success', + 'text': 'You have been logged out' + }) + }) } -</style> +</script> 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 <https://www.gnu.org/licenses/>. 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<string>; + isOriginProtectionOn: boolean; + allowOrigin(origin?:string): Promise<void>; + dissallowOrigin(origin?:string): Promise<void>; + disableOriginProtection(): Promise<void>; + setOriginProtection(value: boolean): Promise<void>; + } +} + +export const originPlugin = ({ store }: PiniaPluginContext) => { + + const { plugins } = store + const status = ref<AllowedOriginStatus>() + + 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<typeof usePlugins> +export type BgPluginState<T> = { 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<Tabs.Tab | undefined>(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<NostrPubKey>): Promise<void>; + createIdentity(id: Partial<NostrPubKey>): Promise<NostrPubKey>; + updateIdentity(id: NostrPubKey): Promise<NostrPubKey>; + selectKey(key: NostrPubKey): Promise<void>; + } +} + + +export const identityPlugin = ({ store }: PiniaPluginContext) => { + + const { identity } = store.plugins + + const allKeys = ref<NostrPubKey[]>([]) + const selectedKey = ref<NostrPubKey | undefined>(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 <https://www.gnu.org/licenses/>. + +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 <https://www.gnu.org/licenses/>. + +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<PkiPubKey[]> + removeKey(kid: PkiPubKey): Promise<void> + isEnabled(): Promise<boolean> +} + +export const usePkiApi = (): IFeatureExport<AppSettings, PkiApi> => { + return{ + background: ({ state } : BgRuntime<AppSettings>):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<PkiApi>([ + 'getAllKeys', + 'removeKey', + 'isEnabled' + ]) + } +} + +interface PkiSettings { + userName: string, + privateKey?:JWK +} + +export interface LocalPkiApi extends FeatureApi { + regenerateKey: (userName:string, params: EcKeyParams) => Promise<void> + getPubKey: () => Promise<PkiPubKey | undefined> + generateOtp: () => Promise<string> +} + +export const useLocalPki = (): IFeatureExport<AppSettings, LocalPkiApi> => { + + return{ + //Setup registration + background: ({ state } : BgRuntime<AppSettings>) =>{ + const { get, set } = useSingleSlotStorage<PkiSettings>(storage.local, 'pki-settings') + + const getPubKey = async (): Promise<PkiPubKey | undefined> => { + 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<LocalPkiApi>([ + '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 <https://www.gnu.org/licenses/>. + +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<T extends JsonObject> { + (message: T): Promise<any> +} + +export interface MessageHandler<T extends JsonObject> { + (message: T): Promise<any> +} + +export interface ApiMessageHandler<T extends JsonObject> { + (message: T, apiHandle: { axios: AxiosInstance }): Promise<any> +} + +export interface UserApi extends FeatureApi { + login: (token: string) => Promise<boolean> + logout: () => Promise<void> + getProfile: () => Promise<any> + getStatus: () => Promise<ClientStatus> + waitForChange: () => Promise<void> +} + +export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { + + return { + background: ({ state, onInstalled }:BgRuntime<AppSettings>): 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<boolean> => { + //Perform login + await login(token) + //load profile + getProfile() + return true; + }), + logout: popupOnly(async (): Promise<void> => { + //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<UserApi>([ + '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 <https://www.gnu.org/licenses/>. + +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<T> { + readonly state: T; + onInstalled(callback: () => Promise<void>): void; + onConnected(callback: () => Promise<void>): void; +} + +export type FeatureApi = { + [key: string]: (... args: any[]) => Promise<any> +}; + +export type SendMessageHandler = <T extends JsonObject | JsonObject[]>(action: string, data: any) => Promise<T> +export type VarArgsFunction<T> = (...args: any[]) => T +export type FeatureConstructor<TState, T extends FeatureApi> = () => IFeatureExport<TState, T> +export type DummyApiExport<T extends FeatureApi> = Array<keyof T> + +export interface IFeatureExport<TState, TFeature extends FeatureApi> { + /** + * 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<TState>): 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: <T extends FeatureApi>(feature: FeatureConstructor<any, T>) => T +} + +export interface IBackgroundWrapper<TState> { + register<T extends FeatureApi>(features: FeatureConstructor<TState, T>[]): void +} + +export interface ProtectedFunction extends Function { + readonly protection: RuntimeContext[] +} + +export const optionsOnly = <T extends Function>(func: T): T => protectMethod(func, 'options'); +export const popupOnly = <T extends Function>(func: T): T => protectMethod(func, 'popup'); +export const contentScriptOnly = <T extends Function>(func: T): T => protectMethod(func, 'content-script'); +export const windowOnly = <T extends Function>(func: T): T => protectMethod(func, 'window'); +export const popupAndOptionsOnly = <T extends Function>(func: T): T => protectMethod(func, 'popup', 'options'); + +export const protectMethod = <T extends Function>(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 = <TState>(state: TState): IBackgroundWrapper<TState> => { + + const rt = { + state, + onConnected: runtime.onConnect.addListener, + onInstalled: runtime.onInstalled.addListener, + } as BgRuntime<TState> + + 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: <TFeature extends FeatureApi>(features: FeatureConstructor<TState, TFeature>[]) => { + //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<any>) => { + 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: <T extends FeatureApi>(feature:FeatureConstructor<any, T>): 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 = <T extends FeatureApi>(args: DummyApiExport<T>): () => 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 <https://www.gnu.org/licenses/>. + +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<AppSettings, HistoryApi> => { + return{ + background: ({ }: BgRuntime<AppSettings>): 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 <https://www.gnu.org/licenses/>. + +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<NostrPubKey> + updateIdentity: (identity: NostrPubKey) => Promise<NostrPubKey> + deleteIdentity: (key: NostrPubKey) => Promise<void> + getAllKeys: () => Promise<NostrPubKey[]>; + getPublicKey: () => Promise<NostrPubKey | undefined>; + selectKey: (key: NostrPubKey) => Promise<void>; +} + +export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { + return{ + background: ({ state }: BgRuntime<AppSettings>) =>{ + const { execRequest } = useServerApi(state); + const { loggedIn } = useSession(); + + //Get the current selected key + const selectedKey = ref<NostrPubKey | undefined>(); + 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<NostrPubKey>(Endpoints.CreateId, id) + triggerChange() + }), + updateIdentity: optionsOnly(async (id: NostrPubKey) => { + await execRequest<NostrPubKey>(Endpoints.UpdateId, id) + triggerChange() + }), + deleteIdentity: optionsOnly(async (key: NostrPubKey) => { + await execRequest<NostrPubKey>(Endpoints.DeleteKey, key); + triggerChange() + }), + selectKey: popupAndOptionsOnly((key: NostrPubKey): Promise<void> => { + selectedKey.value = key; + return Promise.resolve() + }), + getAllKeys: async (): Promise<NostrPubKey[]> => { + if(!get(loggedIn)){ + return [] + } + //Get the keys from the server + const data = await execRequest<NostrPubKey[]>(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<NostrPubKey | undefined> => { + 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 <https://www.gnu.org/licenses/>. + +//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 <https://www.gnu.org/licenses/>. + +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<void>; + removeOrigin(origin?: string): Promise<void>; + getStatus(): Promise<AllowedOriginStatus>; + enable(value: boolean): Promise<void>; +} + +export const useInjectAllowList = (): IFeatureExport<AppSettings, InjectAllowlistApi> => { + return { + background: ({ }: BgRuntime<AppSettings>) => { + + const store = useSingleSlotStorage<AllowedSites>(storage.local, 'nip07-allowlist', { origins: [], enabled: true }); + + //watch current tab + const allowedOrigins = ref<string[]>([]) + const protectionEnabled = ref<boolean>(true) + const [manullyTriggered, trigger] = useToggle() + + const { currentOrigin, currentTab } = (() => { + + const currentTab = ref<Tabs.Tab | undefined>(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<void> => { + //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<void> => { + //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<void> => { + set(protectionEnabled, value) + await writeChanges() + }), + async getStatus(): Promise<AllowedOriginStatus> { + 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 <https://www.gnu.org/licenses/>. + +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<NostrRelay[]>; + signEvent: (event: NostrEvent) => Promise<NostrEvent | undefined>; + setRelay: (relay: NostrRelay) => Promise<NostrRelay | undefined>; + nip04Encrypt: (data: EventMessage) => Promise<string>; + nip04Decrypt: (data: EventMessage) => Promise<string>; +} + +export const useNostrApi = (): IFeatureExport<AppSettings, NostrApi> => { + + return{ + background: ({ state }: BgRuntime<AppSettings>) =>{ + + const { execRequest } = useServerApi(state); + const { filterTags } = useTagFilter() + + return { + getRelays: async (): Promise<NostrRelay[]> => { + //Get preferred relays for the current user + const data = await execRequest<NostrRelay[]>(Endpoints.GetRelays) + return [...data] + }, + signEvent: async (req: NostrEvent): Promise<NostrEvent | undefined> => { + + //If tag filter is enabled, filter before continuing + if(state.currentConfig.value.tagFilter){ + await filterTags(req) + } + + //Sign the event + const event = await execRequest<NostrEvent>(Endpoints.SignEvent, req); + return event; + }, + nip04Encrypt: async (data: EventMessage): Promise<string> => { + return execRequest<string>(Endpoints.Encrypt, data); + }, + nip04Decrypt: (data: EventMessage): Promise<string> => { + return execRequest<string>(Endpoints.Decrypt, data); + }, + setRelay: optionsOnly((relay: NostrRelay): Promise<NostrRelay | undefined> => { + return execRequest<NostrRelay>(Endpoints.SetRelay, relay) + }), + } + }, + foreground: exportForegroundApi([ + 'getRelays', + 'signEvent', + 'setRelay', + 'nip04Encrypt', + 'nip04Decrypt' + ]) + } +} diff --git a/extension/src/entries/background/permissions.ts b/extension/src/features/permissions.ts index 4732bb7..c06257b 100644 --- a/extension/src/entries/background/permissions.ts +++ b/extension/src/features/permissions.ts @@ -17,7 +17,7 @@ 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"; +import { useSettingsApi } from "./settings"; const permissions = useStorageAsync("permissions", [], storage.local); @@ -51,7 +51,7 @@ export const removeAutoAllow = async (origin, mKind, keyId) => { export const useSitePermissions = (() => { const { apiCall, handleProtectedMessage } = useAuthApi(); - const { currentConfig } = useSettings(); + const { currentConfig } = useSettingsApi(); const getCurrentPerms = async () => { diff --git a/extension/src/entries/background/server-api/endpoints.ts b/extension/src/features/server-api/endpoints.ts index 6da9c71..9c73866 100644 --- a/extension/src/entries/background/server-api/endpoints.ts +++ b/extension/src/features/server-api/endpoints.ts @@ -32,15 +32,16 @@ export const initEndponts = () => { const endpoints = new Map<string, EndpointDefinition>(); + //Get local axios + const axios = useAxios(null); + const registerEndpoint = <T extends string>(def: EndpointDefinitionReg<T>) => { //Store the handler by its id endpoints.set(def.id, def); return def; } - const getEndpoint = <T extends string>(id: T): EndpointDefinition | undefined => { - return endpoints.get(id); - } + const getEndpoint = <T extends string>(id: T): EndpointDefinition | undefined => endpoints.get(id); const execRequest = async <T>(id: string, ...request: any): Promise<T> => { const endpoint = getEndpoint(id); @@ -53,12 +54,9 @@ export const initEndponts = () => { //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 }); + 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); 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 <https://www.gnu.org/licenses/>. + + +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<NostrEvent>) => { + 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<NostrEvent>) => 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<NostrEvent>) => response.getResultOrThrow() + }) + + registerEndpoint({ + id: Endpoints.UpdateProfile, + method: 'POST', + path: () => `${get(accUrl)}`, + onRequest: (profile: UserProfile) => Promise.resolve(cloneDeep(profile)), + onResponse: async (response: WebMessage<string>) => 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<string>) => response.getResultOrThrow() + }) + + registerEndpoint({ + id:Endpoints.Decrypt, + method:'POST', + path: () => `${get(nostrUrl)}?type=decrypt`, + onRequest: (data: NostrEvent) => Promise.resolve(data), + onResponse: async (response: WebMessage<string>) => 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 <https://www.gnu.org/licenses/>. + +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<PluginConfig>; + restoreApiSettings: () => Promise<void>; + saveConfig: (config: PluginConfig) => Promise<void>; + readonly currentConfig: Readonly<Ref<PluginConfig>>; +} + +export interface SettingsApi extends FeatureApi, Watchable { + getSiteConfig: () => Promise<PluginConfig>; + setSiteConfig: (config: PluginConfig) => Promise<PluginConfig>; + setDarkMode: (darkMode: boolean) => Promise<void>; + getDarkMode: () => Promise<boolean>; +} + +export const useAppSettings = (): AppSettings => { + const currentConfig = ref<PluginConfig>({} as PluginConfig); + const store = useSingleSlotStorage<PluginConfig>(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<AppSettings, SettingsApi> =>{ + + return{ + background: ({ state, onConnected, onInstalled }: BgRuntime<AppSettings>) => { + + 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<PluginConfig> => { + + //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 <https://www.gnu.org/licenses/>. + +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<void>; + addFilter(tag: string): Promise<void>; + removeFilter(tag: string): Promise<void>; + addFilters(tags: string[]): Promise<void>; +} + +export const useTagFilter = () => { + //use storage + const { get, set } = useSingleSlotStorage<EventTagFilteStorage>(storage.local, 'tag-filter-struct', { filters: [] }); + + return { + filterTags: async (event: TaggedNostrEvent): Promise<void> => { + + 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<AppSettings, EventTagFilterApi> => { + return{ + background: ({ }: BgRuntime<AppSettings>) => { + 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 <https://www.gnu.org/licenses/>. + +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<void>; +} + +export const useStorage = (storage: any & chrome.storage.StorageArea) => { + const get = async <T>(key: string): Promise<T | undefined> => { + const value = await storage.get(key) + return value[key] as T; + } + + const set = async <T>(key: string, value: T): Promise<void> => { + await storage.set({ [key]: value }); + } + + const remove = async (key: string): Promise<void> => { + await storage.remove(key); + } + + return { get, set, remove } +} + +export interface SingleSlotStorage<T>{ + get(): Promise<T | undefined>; + set(value: T): Promise<void>; + remove(): Promise<void>; +} + +export interface DefaultSingleSlotStorage<T>{ + get(): Promise<T>; + set(value: T): Promise<void>; + remove(): Promise<void>; +} + +export interface UseSingleSlotStorage{ + <T>(storage: any & chrome.storage.StorageArea, key: string): SingleSlotStorage<T>; + <T>(storage: any & chrome.storage.StorageArea, key: string, defaultValue: T): DefaultSingleSlotStorage<T>; +} + +const _useSingleSlotStorage = <T>(storage: any & chrome.storage.StorageArea, key: string, defaultValue?: T) => { + const s = useStorage(storage); + + const get = async (): Promise<T | undefined> => { + return await s.get<T>(key) || defaultValue; + } + + const set = (value: T): Promise<void> => s.set(key, value); + const remove = (): Promise<void> => s.remove(key); + + return { get, set, remove } +} + +export const useSingleSlotStorage: UseSingleSlotStorage = _useSingleSlotStorage; + +export const onWatchableChange = (watchable: Watchable, onChangeCallback: () => Promise<any>, 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', ], |