diff options
Diffstat (limited to 'extension/src')
38 files changed, 3216 insertions, 0 deletions
diff --git a/extension/src/assets/tailwind.scss b/extension/src/assets/tailwind.scss new file mode 100644 index 0000000..7d1b1f6 --- /dev/null +++ b/extension/src/assets/tailwind.scss @@ -0,0 +1,354 @@ +@tailwind base; + +@tailwind components; + +@tailwind utilities; + +#injected-root { + + /* HEADINGS */ + h1, h2, h3, h4, h5, h6{ + @apply font-medium leading-tight mt-0 mb-2; + } + h1{ + @apply sm:text-5xl text-4xl; + } + h2{ + @apply sm:text-4xl text-3xl; + } + h3{ + @apply sm:text-3xl text-2xl; + } + h4{ + @apply sm:text-2xl text-xl; + } + h5{ + @apply sm:text-xl text-lg; + } + h6{ + @apply sm:text-base text-sm; + } + + + input.primary, + select.primary, + textarea.primary { + @apply border-2 rounded-md p-2 py-1.5 border-gray-200; + @apply dark:bg-dark-800 dark:border-dark-400 dark:text-white; + + &:focus, + &::after, + &:active{ + @apply outline-none border-primary-500 dark:border-primary-600; + } + + &.error, + &.error:focus, + &.error::after, + &.error:active{ + @apply outline-none border-red-500 dark:border-red-400; + } + } + + /* CHECKBOXES */ + + label.checkbox{ + @apply flex items-center cursor-pointer; + + input[type="checkbox"] { + @apply ease-in-out duration-100 w-5 h-5; + @apply border-2 rounded-sm border-gray-300 dark:border-dark-500; + + &:checked { + @apply text-primary-500 dark:text-primary-600 border-primary-500 dark:border-primary-600; + } + } + + &.primary { + input[type="checkbox"]{ + @apply appearance-none; + @apply hover:border-primary-500 dark:hover:border-primary-600; + + &:checked { + @apply bg-primary-500 dark:bg-primary-600 border-primary-500 dark:border-primary-600; + } + + & + span.check{ + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + @apply bg-white dark:bg-dark-500; + } + } + + span.check{ + margin: 0px 0px 1px 4px; + @apply absolute h-3 w-3; + } + } + + } + + /*Select */ + + select.primary.options{ + @apply text-current; + } + + + /*Validation inputs*/ + + input.primary.dirty.data-valid, + select.primary.dirty.data-valid + { + @apply border-primary-500 dark:border-primary-600; + } + input.primary.dirty.data-invalid, + select.primary.dirty.data-invalid + { + @apply border-red-600 dark:border-red-500; + } + + + input, + input:hover, + input:focus, + input:active, + input::after { + @apply duration-100 ease-in-out outline-none; + } + + .default-page-template { + min-height: 400px; + @apply container w-full max-w-4xl px-4 pt-3 mx-auto sm:pt-6 md:px-0; + } + + #header-mobile-nav a:hover, + #header-desktop-nav a:hover, + #header-mobile-nav .router-link-active, + #header-desktop-nav .router-link-active, + footer .footer-content .router-link-active { + @apply text-primary-500 dark:text-primary-600; + } + + #header-mobile-nav a { + @apply text-xl; + } + + a.link { + @apply duration-150 ease-in-out; + @apply text-purple-500 hover:text-purple-600; + } + + .modal-entry { + background: #00000077; + @apply fixed z-50 flex w-full; + + .modal-content-container { + @apply w-full max-w-md p-5 m-auto rounded-md shadow-2xl md:mt-44 mt-28; + @apply bg-white border border-transparent dark:bg-dark-600 dark:border-primary-500 dark:text-white; + + .modal-title { + @apply text-xl font-bold; + } + + .modal-description { + @apply text-sm; + } + } + + .modal-button-container { + @apply flex flex-row justify-end pt-3 gap-3; + } + + .input-container { + @apply pt-5; + + input { + @apply w-full; + } + } + } + + .btn { + @apply ease-in-out duration-100 border border-transparent px-4 py-2 text-center text-sm font-medium transition-all focus:ring-2; + + @apply bg-white border-gray-300 text-gray-700 shadow-sm hover:bg-gray-100 focus:ring-gray-100; + + .dark & { + @apply bg-transparent text-inherit border-dark-300 hover:bg-transparent hover:border-gray-400 focus:ring-gray-300; + } + + &.b-0 { + @apply border-0 ring-0; + } + + &:disabled { + @apply cursor-not-allowed border-gray-100 bg-gray-50 text-gray-400; + @apply dark:bg-transparent dark:border-dark-400 dark:text-dark-300; + } + + &.sm { + @apply px-3 py-1.5 text-sm; + } + + &.xs { + @apply px-2 py-1; + } + + &.primary { + @apply border-primary-500 bg-primary-500 text-white hover:border-primary-600 hover:bg-primary-600 focus:ring-primary-200; + + &:disabled { + @apply bg-primary-300 border-primary-300; + @apply dark:bg-transparent dark:border-primary-400; + } + + .dark & { + @apply border-primary-600 bg-transparent text-primary-600 hover:border-primary-500 hover:text-primary-500 focus:ring-primary-500; + } + } + + &.secondary { + @apply border-secondary-500 bg-secondary-500 text-white hover:border-secondary-600 hover:bg-secondary-600 focus:ring-secondary-200; + + &:disabled { + @apply bg-secondary-300 border-secondary-300; + @apply dark:bg-transparent dark:border-secondary-400; + } + + .dark & { + @apply border-secondary-600 bg-transparent text-secondary-600 hover:border-secondary-500 hover:text-secondary-500 focus:ring-secondary-500; + } + } + + &.red { + @apply border-red-500 bg-red-500 text-white hover:border-red-600 hover:bg-red-600 focus:ring-red-200; + + &:disabled { + @apply bg-red-300 border-red-300; + @apply dark:bg-transparent dark:border-red-400; + } + + .dark & { + @apply border-red-600 bg-transparent text-red-600 hover:border-red-500 hover:text-red-500 focus:ring-red-500; + } + } + + &.blue { + @apply border-blue-500 bg-blue-500 text-white hover:border-blue-600 hover:bg-blue-600 focus:ring-blue-200; + + &:disabled { + @apply bg-blue-300 border-blue-300; + @apply dark:bg-transparent dark:border-blue-400; + } + + .dark & { + @apply border-blue-600 bg-transparent text-blue-600 hover:border-blue-500 hover:text-blue-500 focus:ring-blue-500; + } + } + + &.green { + @apply border-green-500 bg-green-500 text-white hover:border-green-600 hover:bg-green-600 focus:ring-green-200; + + &:disabled { + @apply bg-green-300 border-green-300; + @apply dark:bg-transparent dark:border-green-400; + } + + .dark & { + @apply border-green-600 bg-transparent text-green-600 hover:border-green-500 hover:text-green-500 focus:ring-green-500; + } + } + + &.yellow { + @apply border-yellow-500 bg-yellow-500 text-white hover:border-yellow-600 hover:bg-yellow-600 focus:ring-yellow-200; + + &:disabled { + @apply bg-yellow-300 border-yellow-300; + @apply dark:bg-transparent dark:border-yellow-400; + } + + .dark & { + @apply border-yellow-400 bg-transparent text-yellow-400 hover:border-yellow-300 hover:text-yellow-300 focus:ring-yellow-300; + } + } + + &.purple { + @apply border-purple-500 bg-purple-500 text-white hover:border-purple-600 hover:bg-purple-600 focus:ring-purple-200; + + &:disabled { + @apply bg-purple-300 border-purple-300; + @apply dark:bg-transparent dark:border-purple-400; + } + + .dark & { + @apply border-purple-600 bg-transparent text-purple-600 hover:border-purple-500 hover:text-purple-500 focus:ring-purple-500; + } + } + + &.pink { + @apply border-pink-500 bg-pink-500 text-white hover:border-pink-600 hover:bg-pink-600 focus:ring-pink-200; + + &:disabled { + @apply bg-pink-300 border-pink-300; + @apply dark:bg-transparent dark:border-pink-400; + } + + .dark & { + @apply border-pink-600 bg-transparent text-pink-600 hover:border-pink-500 hover:text-pink-500 focus:ring-pink-500; + } + } + + &.gray { + @apply border-gray-500 bg-gray-500 text-white hover:border-gray-600 hover:bg-gray-600 focus:ring-gray-200; + + &:disabled { + @apply bg-gray-300 border-gray-300; + @apply dark:bg-transparent dark:border-gray-400; + } + + .dark & { + @apply border-gray-600 bg-transparent text-gray-600 hover:border-gray-500 hover:text-gray-500 focus:ring-gray-500; + } + } + + &.no-border{ + @apply border-0 hover:border-0 focus:border-0 ring-0 focus:ring-0 bg-transparent hover:bg-transparent focus:bg-transparent shadow-none hover:shadow-none focus:shadow-none; + } + } + + .button-group { + @apply inline-flex -space-x-0 divide-x overflow-hidden rounded-lg border border-transparent shadow-sm; + @apply divide-gray-300 border-gray-300 dark:divide-dark-500 dark:border-dark-500; + + & .btn { + @apply border-0 ring-0 focus:ring-0; + } + } + + .general-toast { + .notification-title { + font-size: 16px; + } + + .notification-content { + font-size: 14px; + } + + .vue-notification { + @apply duration-200 ease-in-out shadow-md hover:shadow-lg; + } + } + + .form-toast { + left: calc(50% - 150px); + @apply pt-2 mx-auto mb-3; + + .notification-title { + font-size: 14px; + } + + .notification-content { + font-size: 12px; + } + } +}
\ No newline at end of file diff --git a/extension/src/bg-api/bg-api.ts b/extension/src/bg-api/bg-api.ts new file mode 100644 index 0000000..86e6b68 --- /dev/null +++ b/extension/src/bg-api/bg-api.ts @@ -0,0 +1,145 @@ +// 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 new file mode 100644 index 0000000..7b64e81 --- /dev/null +++ b/extension/src/bg-api/content-script.ts @@ -0,0 +1,48 @@ +// 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 new file mode 100644 index 0000000..4313f35 --- /dev/null +++ b/extension/src/bg-api/options.ts @@ -0,0 +1,80 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <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 new file mode 100644 index 0000000..f81bcfb --- /dev/null +++ b/extension/src/bg-api/popup.ts @@ -0,0 +1,66 @@ +// 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 new file mode 100644 index 0000000..6fc2f84 --- /dev/null +++ b/extension/src/bg-api/types.ts @@ -0,0 +1,50 @@ +// 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 new file mode 100644 index 0000000..ed18b5b --- /dev/null +++ b/extension/src/entries/background/auth-api.ts @@ -0,0 +1,135 @@ +// 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: BridgeMessage<T>): 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 protect = <T extends JsonObject>(cbHandler: ProectedHandler<T>) =>{ + return (message: BridgeMessage<T>) : Promise<any> => { + if (message.sender.context === 'options' || message.sender.context === 'popup') { + return cbHandler(message) + } + throw new Error('Unauthorized') + } + } + + const onLogin = protect(async ({data} : BridgeMessage<LoginMessage>): Promise<any> => { + + //Perform login + return await apiCall(async ({ axios }) => { + const { login } = usePkiAuth(`${currentConfig.value.accountBasePath}/pki`); + await login(data.token) + return true; + }) + }) + + const onLogout = protect(async () : Promise<void> => { + return await apiCall(async () => { + await logout() + //Cleanup after logout + clearLoginState() + }) + }) + + const onGetProfile = protect(async () : Promise<any> => { + return await apiCall(async () => await 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, + protect, + 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 new file mode 100644 index 0000000..b3f3733 --- /dev/null +++ b/extension/src/entries/background/history.ts @@ -0,0 +1,82 @@ +// 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 new file mode 100644 index 0000000..612f36e --- /dev/null +++ b/extension/src/entries/background/identity-api.ts @@ -0,0 +1,61 @@ +// 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 { useAuthApi } from "./auth-api"; +import { useSettings } from "./settings"; + +export const useIdentityApi = (() => { + + const { apiCall, protect } = useAuthApi(); + const { currentConfig } = useSettings(); + + const onCreateIdentity = protect(async ({data}) => { + //Create a new identity + return await apiCall(async ({ axios }) => { + 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 = protect(async ({data}) => { + return await apiCall(async ({ 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 new file mode 100644 index 0000000..b4080d6 --- /dev/null +++ b/extension/src/entries/background/main.ts @@ -0,0 +1,105 @@ +// 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 { HistoryEvent, 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, protect } = 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', protect(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 diff --git a/extension/src/entries/background/nostr-api.ts b/extension/src/entries/background/nostr-api.ts new file mode 100644 index 0000000..fb9130b --- /dev/null +++ b/extension/src/entries/background/nostr-api.ts @@ -0,0 +1,124 @@ +// 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 { BridgeMessage } from "webext-bridge"; +import { NostrRelay, NostrPubKey, EventMessage, NostrEvent } from './types' +import { Endpoints, initApi } from "./server-api"; + +export const useNostrApi = (() => { + + const { currentConfig } = useSettings(); + const { apiCall, protect, 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) + + const onGetPubKey = () => { + //Selected key is allowed from content script + return { ...selectedKey.value } + } + + const onDeleteKey = protect<NostrPubKey>(({ data }) => apiCall(() => execRequest<NostrPubKey>(Endpoints.DeleteKey, data))) + + const onSelectKey = protect<NostrPubKey>(async ({ data }) => { + //Set the selected key to the value + selectedKey.value = data + }) + + const onGetAllKeys = protect(async () => { + return await apiCall(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 this handler so it can be called from the content script + const onSignEvent = (async ({ data }: BridgeMessage<EventMessage>) => { + //Set the key id from our current selection + data.event.KeyId = selectedKey.value?.Id || ''; //Pass key selection error to server + + //Sign the event + return await apiCall(async () => { + //Sign the event + const event = await execRequest<NostrEvent>(Endpoints.SignEvent, data.event); + return { event }; + }) + }) + + const onGetRelays = async () => { + return await apiCall(async () => { + //Get preferred relays for the current user + const data = await execRequest<NostrRelay[]>(Endpoints.GetRelays) + return [ ...data ] + }) + } + + + const onSetRelay = protect<NostrRelay>(({ data }) => apiCall(() => execRequest<NostrRelay>(Endpoints.SetRelay, data))); + + const onNip04Encrypt = protect(async ({ data }) => { + console.log('nip04.encrypt', data) + return { ciphertext: 'ciphertext' } + }) + + const onNip04Decrypt = protect(async ({ data }) => { + console.log('nip04.decrypt', data) + return { plaintext: 'plaintext' } + }) + + //Clear the selected key if the user logs out + watch(loggedIn, (li) => li ? null : selectedKey.value = null) + + return () => { + return{ + selectedKey, + nostrUrl, + onGetPubKey, + onSelectKey, + onGetAllKeys, + onSignEvent, + onGetRelays, + onSetRelay, + onNip04Encrypt, + onNip04Decrypt, + onDeleteKey + } + } +})()
\ No newline at end of file diff --git a/extension/src/entries/background/permissions.ts b/extension/src/entries/background/permissions.ts new file mode 100644 index 0000000..f12c84c --- /dev/null +++ b/extension/src/entries/background/permissions.ts @@ -0,0 +1,80 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import { useStorageAsync } from "@vueuse/core"; +import { find, isEmpty, remove } from "lodash"; +import { storage } from "webextension-polyfill"; +import { useAuthApi } from "./auth-api"; +import { useSettings } from "./settings"; + +const permissions = useStorageAsync("permissions", [], storage.local); + +export const setAutoAllow = async (origin, mKind, keyId) => { + permissions.value.push({ origin, mKind, keyId, }) +} + +/** + * Determines if the user has previously allowed the origin to use the key to sign events + * of the desired kind + * @param {*} origin The site origin requesting the permission + * @param {*} mKind The kind of message being signed + * @param {*} keyId The keyId of the key being used to sign the message + */ +export const isAutoAllow = async (origin, mKind, keyId) => { + return find(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId) !== undefined +} + +/** + * Removes the auto allow permission from the list + * @param {*} origin The site origin requesting the permission + * @param {*} mKind The message kind being signed + * @param {*} keyId The keyId of the key being used to sign the message + */ +export const removeAutoAllow = async (origin, mKind, keyId) => { + //Remove the permission from the list + remove(permissions.value, p => p.origin === origin && p.mKind === mKind && p.keyId === keyId); +} + + +export const useSitePermissions = (() => { + + const { apiCall, protect } = useAuthApi(); + const { currentConfig } = useSettings(); + + + const getCurrentPerms = async () => { + const { permissions } = await storage.local.get('permissions'); + + //Store a default config if none exists + if (isEmpty(permissions)) { + await storage.local.set({ siteConfig: defaultConfig }); + } + + //Merge the default config with the site config + return merge(defaultConfig, siteConfig) + } + + const onIsSiteEnabled = protect(async ({ data }) => { + + }) + + return () => { + return { + onCreateIdentity, + onUpdateIdentity + } + } + +})()
\ No newline at end of file diff --git a/extension/src/entries/background/script.js b/extension/src/entries/background/script.js new file mode 100644 index 0000000..b0211d1 --- /dev/null +++ b/extension/src/entries/background/script.js @@ -0,0 +1,16 @@ +// 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 "./main.ts"; diff --git a/extension/src/entries/background/server-api/endpoints.ts b/extension/src/entries/background/server-api/endpoints.ts new file mode 100644 index 0000000..a7f1488 --- /dev/null +++ b/extension/src/entries/background/server-api/endpoints.ts @@ -0,0 +1,72 @@ +// 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 { useAxios } from "@vnuge/vnlib.browser"; +import { Method } from "axios"; + +export interface EndpointDefinition { + readonly method: Method + path(request?: any): string + onRequest: (request?: any) => Promise<any> + onResponse: (response: any, request?: any) => Promise<any> +} + +export interface EndpointDefinitionReg<T extends string> extends EndpointDefinition { + readonly id: T +} + +export const initEndponts = () => { + + const endpoints = new Map<string, EndpointDefinition>(); + + 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 execRequest = async <T>(id: string, request?: any): Promise<T> => { + const endpoint = getEndpoint(id); + if (!endpoint) { + throw new Error(`Endpoint ${id} not found`); + } + + //Compute the path from the request + const path = endpoint.path(request); + + //Execute the request handler + const req = await endpoint.onRequest(request); + + //Get axios + const axios = useAxios(null); + + //Exec the request + const { data } = await axios({ method: endpoint.method, url: path, data: req }); + + //exec the response handler and return its result + return await endpoint.onResponse(data, request); + } + + return { + registerEndpoint, + getEndpoint, + execRequest + } +} diff --git a/extension/src/entries/background/server-api/index.ts b/extension/src/entries/background/server-api/index.ts new file mode 100644 index 0000000..3e1ada0 --- /dev/null +++ b/extension/src/entries/background/server-api/index.ts @@ -0,0 +1,80 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + + +import { Ref } from "vue" +import { WebMessage } from "@vnuge/vnlib.browser" +import { initEndponts } from "./endpoints" +import { NostrEvent, NostrPubKey, NostrRelay } from "../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: () => `${nostrUrl.value}?type=getKeys`, + onRequest: () => Promise.resolve(), + onResponse: (response) => Promise.resolve(response) + }) + + registerEndpoint({ + id: Endpoints.DeleteKey, + method: 'DELETE', + path: (key: NostrPubKey) => `${nostrUrl.value}?type=identity&key_id=${key.Id}`, + onRequest: () => Promise.resolve(), + onResponse: (response: WebMessage) => response.getResultOrThrow() + }) + + registerEndpoint({ + id: Endpoints.SignEvent, + method: 'POST', + path: () => `${nostrUrl.value}?type=signEvent`, + onRequest: (event: NostrEvent) => Promise.resolve(event), + onResponse: async (response: WebMessage<NostrEvent>) => { + return response.getResultOrThrow() + } + }) + + registerEndpoint({ + id: Endpoints.GetRelays, + method: 'GET', + path: () => `${nostrUrl.value}?type=getRelays`, + onRequest: () => Promise.resolve(), + onResponse: (response) => Promise.resolve(response) + }) + + registerEndpoint({ + id: Endpoints.SetRelay, + method: 'POST', + path: () => `${nostrUrl.value}?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/serviceWorker.ts b/extension/src/entries/background/serviceWorker.ts new file mode 100644 index 0000000..cb6f42a --- /dev/null +++ b/extension/src/entries/background/serviceWorker.ts @@ -0,0 +1,16 @@ +// 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 "./main"; diff --git a/extension/src/entries/background/settings.ts b/extension/src/entries/background/settings.ts new file mode 100644 index 0000000..98d6aa6 --- /dev/null +++ b/extension/src/entries/background/settings.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 { 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 new file mode 100644 index 0000000..d459ea1 --- /dev/null +++ b/extension/src/entries/background/types.ts @@ -0,0 +1,76 @@ +// 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 new file mode 100644 index 0000000..26b17a9 --- /dev/null +++ b/extension/src/entries/contentScript/nostr-shim.js @@ -0,0 +1,87 @@ +// 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 { isEqual, isNil, isEmpty } from 'lodash' +import { sendMessage } from 'webext-bridge/content-script' +import { apiCall } from '@vnuge/vnlib.browser' +import { useManagment } from './../../bg-api/content-script' + +const { getSiteConfig } = useManagment() +const nip07Enabled = () => getSiteConfig().then(p => p.autoInject); + +//Setup listener for the content script to process nostr messages + +const ext = '@vnuge/nvault-extension' + +let _promptHandler = () => {} + +export const usePrompt = (callback) => { + //Register the callback + _promptHandler = async (event) => { + return new Promise((resolve, reject) => { + callback(event).then(resolve).catch(reject) + }) + } + return {} +} + +//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); + + //Only listen for messages if injection is enabled + window.addEventListener('message', async ({ source, data, origin }) => { + //Confirm the message format is correct + if (!isEqual(source, window) || isEmpty(data) || isNil(data.type)) { + return + } + //Confirm extension is for us + if (!isEqual(data.ext, ext)) { + return + } + + // pass on to background + var response; + await apiCall(async () => { + switch (data.type) { + case 'getPublicKey': + case 'signEvent': + //Check the public key against selected key + case 'getRelays': + case 'nip04.encrypt': + case 'nip04.decrypt': + //await propmt for user to allow the request + const allow = await _promptHandler({ ...data, origin }) + //send request to background + response = allow ? await sendMessage(data.type, { ...data.payload, origin }) : { error: 'User denied permission' } + break; + default: + throw new Error('Unknown nostr message type') + } + }) + // return response message, must have the same id as the request + window.postMessage({ ext, id: data.id, response }, origin); + }); + } +}) diff --git a/extension/src/entries/contentScript/primary/App.vue b/extension/src/entries/contentScript/primary/App.vue new file mode 100644 index 0000000..05dfac0 --- /dev/null +++ b/extension/src/entries/contentScript/primary/App.vue @@ -0,0 +1,17 @@ +<template> + <html> + <body id="injected-root"> + <notifications class="toaster" group="form" position="top-right" /> + <Prompt></Prompt> + </body> + </html> +</template> + +<script setup lang="ts"> +import { configureNotifier } from '@vnuge/vnlib.browser'; +import { notify } from "@kyvg/vue3-notification"; +import Prompt from './components/PromptPopup.vue' + +configureNotifier({ notify, close: notify.close }) + +</script>
\ 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 new file mode 100644 index 0000000..057f66a --- /dev/null +++ b/extension/src/entries/contentScript/primary/components/PromptPopup.vue @@ -0,0 +1,148 @@ +<template> + <div v-show="isOpen" id="nvault-ext-prompt"> + <div class="relative text-white" style="z-index:9147483647 !important" ref="prompt"> + <div class="fixed inset-0 left-0 flex justify-center w-full h-full p-4 bg-black/50"> + <div class="relative w-full max-w-md mx-auto mt-20 mb-auto"> + <div class="w-full p-4 border rounded-lg shadow-lg bg-dark-700 border-dark-400"> + <div v-if="loggedIn" class=""> + <h3 class="">Allow access</h3> + <div class="pl-1 text-sm"> + 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> + </div> + <div class="mt-5 text-center"> + <span class="text-primary-500">{{ site }}</span> + would like to access to + <span class="text-yellow-500">{{ event.msg }}</span> + </div> + <div class="flex gap-2 mt-4"> + <div class=""> + <Popover class="relative"> + <PopoverButton class="rounded btn sm">View Raw</PopoverButton> + <PopoverPanel class="absolute z-10"> + <div class="min-w-[22rem] p-2 border rounded bg-dark-700 border-dark-400 shadow-md text-sm"> + <p class="pl-1"> + Event Data: + </p> + <div class="p-2 mt-1 text-left border rounded border-dark-400 bg-dark-600 overflow-y-auto max-h-[22rem]"> +<pre> +{{ evData }} +</pre> + </div> + </div> + </PopoverPanel> + </Popover> + </div> + <div class="ml-auto"> + <button :disabled="selectedKey?.Id == undefined" class="rounded btn primary sm" @click="allow">Allow</button> + </div> + <div> + <button class="rounded btn sm red" @click="close">Close</button> + </div> + </div> + </div> + <div v-else class=""> + <h3 class="">Log in!</h3> + <div class=""> + You must log in before you can allow access. + </div> + <div class="flex justify-end gap-2 mt-4"> + <div> + <button class="rounded btn sm red" @click="close">Close</button> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref } from 'vue' +import { usePrompt } from '~/entries/contentScript/nostr-shim' +import { computed } from '@vue/reactivity'; +import { onClickOutside } from '@vueuse/core'; +import { useStatus } from '~/bg-api/content-script.ts'; +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue' +import { first } from 'lodash'; + +const { loggedIn, selectedKey } = useStatus() + +const prompt = ref(null) + +interface PopupEvent{ + type: string + msg: string + origin: string + data: any + allow: () => void + close: () => void +} + +const evStack = ref<PopupEvent[]>([]) +const isOpen = computed(() => evStack.value.length > 0) +const event = computed<PopupEvent | undefined>(() => first(evStack.value)); + +const site = computed(() => new URL(event.value?.origin || "https://example.com").host) +const evData = computed(() => JSON.stringify(event.value || {}, null, 2)) + + +const close = () => { + //Pop the first event off + const res = evStack.value.shift() + res?.close() +} +const allow = () => { + //Pop the first event off + const res = evStack.value.shift() + res?.allow() +} + +//Setup click outside +//onClickOutside(prompt, () => isOpen.value ? close() : null) + +//Listen for events +usePrompt(async (ev: PopupEvent) => { + + console.log('usePrompt', ev) + + switch(ev.type){ + case 'getPublicKey': + ev.msg = "your public key" + break; + case 'signEvent': + ev.msg = "sign an event" + break; + case 'getRelays': + ev.msg = "get your preferred relays" + break; + case 'nip04.encrypt': + ev.msg = "encrypt data" + break; + case 'nip04.decrypt': + ev.msg = "decrypt data" + break; + } + + return new Promise((resolve, reject) => { + evStack.value.push({ + ...ev, + allow: () => resolve(true), + close: () => resolve(false), + }) + }) +}) + + +</script> + +<style lang="scss"> + + +</style> diff --git a/extension/src/entries/contentScript/primary/main.js b/extension/src/entries/contentScript/primary/main.js new file mode 100644 index 0000000..24ef4ef --- /dev/null +++ b/extension/src/entries/contentScript/primary/main.js @@ -0,0 +1,41 @@ +// 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 { createApp } from "vue"; +import renderContent from "../renderContent"; +import App from "./App.vue"; +import Notification from '@kyvg/vue3-notification' +import '@fontsource/noto-sans-masaram-gondi' + +//We need inline styles to inject into the shadow dom +import tw from "~/assets/tailwind.scss?inline"; +import localStyle from './style.scss?inline' + +renderContent([], (appRoot, shadowRoot) => { + createApp(App) + .use(Notification) + .mount(appRoot); + + //Add tailwind styles just to the shadow dom element + const style = document.createElement('style') + style.innerHTML = tw.toString() + shadowRoot.appendChild(style) + + //Add local styles + const style2 = document.createElement('style') + style2.innerHTML = localStyle.toString() + shadowRoot.appendChild(style2) +});
\ No newline at end of file diff --git a/extension/src/entries/contentScript/primary/style.scss b/extension/src/entries/contentScript/primary/style.scss new file mode 100644 index 0000000..bcdbbfd --- /dev/null +++ b/extension/src/entries/contentScript/primary/style.scss @@ -0,0 +1,15 @@ + +#injected-root{ + + .toaster{ + @apply fixed top-10 right-2 z-[999999999] max-w-[250px]; + } + + .vue-notification-template.vue-notification.error{ + @apply bg-red-500 text-white px-4 py-2; + + .notification-title{ + + } + } +}
\ No newline at end of file diff --git a/extension/src/entries/contentScript/renderContent.js b/extension/src/entries/contentScript/renderContent.js new file mode 100644 index 0000000..84c5b9f --- /dev/null +++ b/extension/src/entries/contentScript/renderContent.js @@ -0,0 +1,48 @@ +// 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"; + +export default async function renderContent( + cssPaths, + render = (_appRoot) => {} +) { + const appContainer = document.createElement("div"); + const shadowRoot = appContainer.attachShadow({ + mode: import.meta.env.DEV ? "open" : "closed", + }); + const appRoot = document.createElement("div"); + + if (import.meta.hot) { + const { addViteStyleTarget } = await import( + "@samrum/vite-plugin-web-extension/client" + ); + + await addViteStyleTarget(shadowRoot); + } else { + cssPaths.forEach((cssPath) => { + const styleEl = document.createElement("link"); + styleEl.setAttribute("rel", "stylesheet"); + styleEl.setAttribute("href", runtime.getURL(cssPath)); + shadowRoot.appendChild(styleEl); + }); + } + + shadowRoot.appendChild(appRoot); + document.body.appendChild(appContainer); + + render(appRoot, shadowRoot); +}
\ No newline at end of file diff --git a/extension/src/entries/nostr-provider.js b/extension/src/entries/nostr-provider.js new file mode 100644 index 0000000..1b8807f --- /dev/null +++ b/extension/src/entries/nostr-provider.js @@ -0,0 +1,92 @@ +// 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/>. + +const waiting = new Map(); + +const ext = '@vnuge/nvault-extension' + +const debugLog = (...args) => { + console.log(`[${ext}]`, ...args) +} + +const sendMessage = (type, payload) => new Promise((resolve, reject) => { + const id = Math.random().toString(36); + waiting.set(id, { resolve, reject }); + window.postMessage({ type, payload, id, ext }, '*'); +}); + +/** + * Listen for messages from the content script + */ +window.addEventListener('message', ({ data }) => { + + //Confirm the message format is correct + if (!data || !data.response || data.ext !== ext || !waiting.get(data.id)){ + return; + } + + debugLog(data) + + //Explode now valid + const { response, id } = data; + + const { resolve, reject } = waiting.get(id); + + if (response.error) { + + //Construct an error object from the resopnse message + const errorMessage = response.error.message ?? response.error; + + let error = new Error(`${ext}: ${errorMessage}`); + error.stack = response.error.stack; + + //Reject the promise as error + reject(error); + + } else { + //Resolve the promise as success + resolve(response); + } + + //Remove the waiter from the list + waiting.delete(id) +}); + + +//Expose the Nostr API to the window object +window.nostr = { + + //Redirect calls to the background script + async getPublicKey(){ + const { PublicKey } = await sendMessage('getPublicKey', {}) + return PublicKey + } , + + async signEvent(event){ + const { event:ev } = await sendMessage('signEvent', { event }) + debugLog("Signed event", ev); + return ev + }, + + async getRelays(){ + const { relays } = await sendMessage('getRelays', {}) + return relays + }, + + nip04: { + encrypt: (peer, plaintext) => sendMessage('nip04.encrypt', { peer, plaintext }), + decrypt: (peer, ciphertext) => sendMessage('nip04.decrypt', { peer, ciphertext }), + }, +};
\ No newline at end of file diff --git a/extension/src/entries/options/App.vue b/extension/src/entries/options/App.vue new file mode 100644 index 0000000..d44a4ff --- /dev/null +++ b/extension/src/entries/options/App.vue @@ -0,0 +1,199 @@ +<template> + <main id="injected-root"> + + <notifications class="toaster" group="form" position="top-right" /> + + <div class="container flex w-full p-4 mx-auto mt-8 text-gray-800 dark:text-gray-200"> + <div class="w-full max-w-4xl mx-auto"> + <div class=""> + <h3>Nostr Vault</h3> + </div> + <TabGroup :selected-index="selectedTab" @change="id => selectedTab = id" > + <TabList class="flex gap-3 pb-2 border-b border-gray-300 dark:border-dark-500"> + <Tab v-slot="{ selected }"> + <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']"> + Identities + </button> + </Tab> + <Tab v-slot="{ selected }"> + <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']"> + Privacy + </button> + </Tab> + <Tab v-slot="{ selected }"> + <button class="border-b-2" :class="[selected ? 'border-primary-500' : 'border-transparent']"> + Settings + </button> + </Tab> + <Tab> + <!-- Hidden for editing --> + </Tab> + <div class="m-auto"> + <div class=""> + <!-- Add spinner --> + + </div> + </div> + <div class="hidden my-auto text-sm font-semibold sm:block"> + <div v-if="userName"> + {{ userName }} + </div> + <div v-else> + <div> + Sign In + </div> + </div> + </div> + <div class="ml-auto sm:ml-0"> + <button class="rounded btn xs" @click="toggleDark()" > + <fa-icon v-if="darkMode" icon="sun"/> + <fa-icon v-else icon="moon" /> + </button> + </div> + </TabList> + <TabPanels> + <TabPanel class="mt-4"> + <Identities :all-keys="allKeys" @edit-key="editKey" @update-all="reloadKeys"/> + </TabPanel> + <TabPanel> + <Privacy/> + </TabPanel> + <TabPanel> + <SiteSettings/> + </TabPanel> + <TabPanel> + <div class="flex flex-col px-2 mt-4"> + <div class="absolute mx-auto"> + <h4>Edit Identity</h4> + </div> + <div class="ml-auto"> + <button class="rounded btn sm" @click.self="doneEditing"> + <fa-icon class="mr-2" icon="chevron-left"/> + Back + </button> + </div> + <div class="flex flex-col mx-auto mt-2"> + <div class="text-sm break-all"> + Internal Id : {{ keyBuffer?.Id }} + </div> + <div class="text-sm break-all"> + Public Key : {{ keyBuffer?.PublicKey }} + </div> + <div class="flex flex-col w-full max-w-md mx-auto mt-3"> + <div class=""> + <div class="text-sm">User Name</div> + <input class="w-full primary" type="text" v-model="keyBuffer.UserName"/> + </div> + <div class="gap-2 my-3 ml-auto"> + <button class="rounded btn sm primary" @click="onUpdate">Update</button> + </div> + </div> + </div> + </div> + </TabPanel> + </TabPanels> + </TabGroup> + </div> + </div> + </main> +</template> + +<script setup lang="ts"> +import { ref, watchEffect } from "vue"; +import { + TabGroup, + TabList, + Tab, + TabPanels, + TabPanel, +} from '@headlessui/vue' +import { configureNotifier } from '@vnuge/vnlib.browser'; +import { useManagment, useStatus, NostrPubKey } from '~/bg-api/options.ts'; +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"; + +//Configure the notifier to use the notification library +configureNotifier({ notify, close: notify.close }) + +const { userName, darkMode } = useStatus() +const { getAllKeys, updateIdentity, getSiteConfig, saveSiteConfig } = useManagment() + +const selectedTab = ref(0) +const allKeys = ref([]) +const keyBuffer = ref(null) + +const editKey = (key: NostrPubKey) =>{ + //Goto hidden tab + selectedTab.value = 3 + //Set selected key + keyBuffer.value = { ...key } +} + +const doneEditing = () =>{ + //Goto hidden tab + selectedTab.value = 0 + //Set selected key + keyBuffer.value = null +} + +const onUpdate = async () =>{ + //Update identity + await updateIdentity(keyBuffer.value) + //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 }) + +//Watch for dark mode changes and update the body class +watchEffect(() => darkMode.value ? document.body.classList.add('dark') : document.body.classList.remove('dark')); + +</script> + +<style lang="scss" scoped> + +main { + font-family: Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.toaster{ + position: fixed; + top: 15px; + right: 0; + z-index: 9999; + max-width: 230px; +} + +.id-card{ + @apply flex md:flex-row flex-col gap-2 p-3 text-sm duration-75 ease-in-out border-2 rounded-lg shadow-md cursor-pointer; + @apply bg-white dark:bg-dark-700 border-gray-200 hover:border-gray-400 dark:border-dark-500 hover:dark:border-dark-200; + + &.selected{ + @apply border-primary-500 hover:border-primary-500; + } +} + +</style>
\ No newline at end of file diff --git a/extension/src/entries/options/components/Identities.vue b/extension/src/entries/options/components/Identities.vue new file mode 100644 index 0000000..c86a6ac --- /dev/null +++ b/extension/src/entries/options/components/Identities.vue @@ -0,0 +1,188 @@ +<template> + <div class="sm:px-3"> + <div class="flex justify-end gap-2"> + <div class=""> + <div class=""> + <button class="rounded btn sm" @click="onNip05Download"> + NIP-05 + <fa-icon icon="download" class="ml-1" /> + </button> + </div> + </div> + <div class="mb-2"> + <Popover class="relative" v-slot="{ open }"> + <PopoverButton class="rounded btn primary sm">Create</PopoverButton> + <PopoverOverlay v-if="open" class="fixed inset-0 bg-black opacity-30" /> + <PopoverPanel class="absolute z-10 mt-2 md:-left-12" v-slot="{ close }"> + <div class="p-4 bg-white border border-gray-200 rounded-md shadow-lg dark:border-dark-300 dark:bg-dark-700"> + <div class="text-sm w-72"> + <form @submit.prevent="e => onCreate(e, close)"> + Create new nostr identity + <div class="mt-2"> + <input class="w-full primary" type="text" name="username" placeholder="User Name"/> + </div> + <div class="mt-2"> + <input class="w-full primary" type="text" name="key" placeholder="Existing key?"/> + <div class="p-1.5 text-xs text-gray-600 dark:text-gray-300"> + Optional, hexadecimal private key (64 characters) + </div> + </div> + <div class="flex justify-end mt-2"> + <button class="rounded btn sm primary" type="submit">Create</button> + </div> + </form> + </div> + </div> + </PopoverPanel> + </Popover> + </div> + </div> + <div v-for="key in allKeys" :key="key" class="mt-2 mb-3"> + <div class="id-card" :class="{'selected': isSelected(key)}" @click.self="selectKey(key)"> + + <div class="flex flex-col min-w-0" @click="selectKey(key)"> + <div class="py-2"> + + <table class="w-full text-sm text-left border-collapse"> + <thead class=""> + <tr> + <th scope="col" class="p-2 font-medium">Nip 05</th> + <th scope="col" class="p-2 font-medium">Modified</th> + <th scope="col" class="p-2 font-medium"></th> + </tr> + </thead> + <tbody class="border-t border-gray-100 divide-y divide-gray-100 dark:border-dark-500 dark:divide-dark-500"> + <tr> + <th class="p-2 font-medium">{{ key.UserName }}</th> + <td class="p-2">{{ prettyPrintDate(key) }}</td> + <td class="flex justify-end p-2 ml-auto text-sm font-medium"> + <div class="ml-auto button-group"> + <button class="btn sm borderless" @click="copy(key.PublicKey)"> + <fa-icon icon="copy"/> + </button> + <button class="btn sm borderless" @click="editKey(key)"> + <fa-icon icon="edit"/> + </button> + <button class="btn sm red borderless" @click="onDeleteKey(key)"> + <fa-icon icon="trash" /> + </button> + </div> + </td> + </tr> + </tbody> + </table> + + </div> + <div class="py-2 overflow-hidden border-gray-500 border-y dark:border-dark-500 text-ellipsis"> + <span class="font-semibold">pub:</span> + <span class="ml-1">{{ key.PublicKey }}</span> + </div> + <div class="py-2"> + <strong>Id:</strong> {{ key.Id }} + </div> + </div> + </div> + </div> + <a class="hidden" ref="downloadAnchor"></a> + </div> +</template> + +<script setup lang="ts"> + +import { isEqual, map } from 'lodash' +import { ref, toRefs } from "vue"; +import { + Popover, + PopoverButton, + PopoverPanel +} from '@headlessui/vue' +import { apiCall, configureNotifier } from '@vnuge/vnlib.browser'; +import { useManagment, useStatus } from '~/bg-api/options.ts'; +import { notify } from "@kyvg/vue3-notification"; +import { useClipboard } from '@vueuse/core'; +import { NostrIdentiy } from '~/bg-api/bg-api'; +import { NostrPubKey } from '../../background/types'; + +const emit = defineEmits(['edit-key', 'update-all']) +const props = defineProps<{ + allKeys:NostrIdentiy[] +}>() + +const { allKeys } = toRefs(props) + +//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 { copy } = useClipboard() + +const isSelected = (me : NostrIdentiy) => isEqual(me, selectedKey.value) + +const editKey = (key : NostrIdentiy) => emit('edit-key', key); + +const onCreate = async (e: Event, onClose : () => void) => { + + //get username input from event + const UserName = e.target['username']?.value as string + //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'); + onClose() +} + +const prettyPrintDate = (key : NostrIdentiy) => { + const d = new Date(key.LastModified) + return `${d.toLocaleDateString()} ${d.toLocaleTimeString()}` +} + +const onDeleteKey = async (key : NostrIdentiy) => { + + if(!confirm(`Are you sure you want to delete ${key.UserName}?`)){ + return; + } + + //Delete identity + await deleteIdentity(key) + + //Update keys + emit('update-all'); +} + +const onNip05Download = () => { + apiCall(async () => { + //Get all public keys from the server + const keys = await getAllKeys() as NostrPubKey[] + const nip05 = {} + //Map the keys to the NIP-05 format + map(keys, k => nip05[k.UserName] = k.PublicKey) + //create file blob + const blob = new Blob([JSON.stringify({ names:nip05 })], { type: 'application/json' }) + + //Download the file + downloadAnchor.value!.href = URL.createObjectURL(blob); + downloadAnchor.value?.setAttribute('download', 'nostr.json') + downloadAnchor.value?.click(); + + }) +} + +</script> + +<style scoped lang="scss"> + +.id-card{ + @apply flex md:flex-row flex-col gap-2 p-3 px-12 text-sm duration-75 ease-in-out border-2 rounded-lg shadow-md cursor-pointer w-fit mx-auto; + @apply bg-white dark:bg-dark-800 border-gray-200 hover:border-gray-400 dark:border-dark-500 hover:dark:border-dark-200; + + &.selected{ + @apply border-primary-500 hover:border-primary-500; + } +} + +</style>
\ No newline at end of file diff --git a/extension/src/entries/options/components/Privacy.vue b/extension/src/entries/options/components/Privacy.vue new file mode 100644 index 0000000..7d2ce4d --- /dev/null +++ b/extension/src/entries/options/components/Privacy.vue @@ -0,0 +1,9 @@ +<template> + <div class="flex flex-col w-full mt-4 sm:px-2"> + + </div> +</template> + +<script setup lang="ts"> + +</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 new file mode 100644 index 0000000..eafe8f3 --- /dev/null +++ b/extension/src/entries/options/components/SiteSettings.vue @@ -0,0 +1,235 @@ +<template> + <div class="flex flex-col w-full mt-4 sm:px-2"> + + <form @submit.prevent=""> + <div class="w-full max-w-md mx-auto"> + <h3 class="text-center"> + Extension settings + </h3> + <div class="my-6"> + <fieldset :disabled="waiting"> + <div class="w-full"> + <div class="flex flex-row justify-between"> + <label class="mr-2">Always on NIP-07</label> + <Switch + v-model="buffer.autoInject" + :class="buffer.autoInject ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'" + class="relative inline-flex items-center h-6 ml-auto rounded-full w-11" + > + <span class="sr-only">NIP-07</span> + <span + :class="buffer.autoInject ? 'translate-x-6' : 'translate-x-1'" + class="inline-block w-4 h-4 transition transform bg-white rounded-full" + /> + </Switch> + </div> + </div> + <p class="mt-1 text-xs"> + Enable auto injection of <code>window.nostr</code> support to all websites. Sites may be able to + track you if you enable this feature. + </p> + </fieldset> + </div> + <h3 class="text-center"> + Server settings + </h3> + <p class="text-sm"> + You must be careful when editing these settings as you may loose connection to your vault + server if you input the wrong values. + </p> + <div class="flex justify-end mt-2"> + <div class="button-group"> + <button class="rounded btn sm" @click="toggleEdit()"> + <fa-icon v-if="editMode" icon="lock-open"/> + <fa-icon v-else icon="lock"/> + </button> + <a :href="data.apiUrl" target="_blank"> + <button type="button" class="rounded btn sm"> + <fa-icon icon="external-link-alt"/> + </button> + </a> + </div> + </div> + <fieldset :disabled="waiting || !editMode"> + <div class="pl-1 mt-2"> + <div class="flex flex-row w-full"> + <div> + <label class="mb-2">Stay logged in</label> + <Switch + v-model="v$.heartbeat.$model" + :class="v$.heartbeat.$model ? 'bg-primary-500 dark:bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'" + class="relative inline-flex items-center h-6 mx-auto rounded-full w-11" + > + <span class="sr-only">Stay logged in</span> + <span + :class="v$.heartbeat.$model ? 'translate-x-6' : 'translate-x-1'" + class="inline-block w-4 h-4 transition transform bg-white rounded-full" + /> + </Switch> + </div> + <div class="my-auto text-xs"> + Enables keepalive messages to regenerate credentials when they expire + </div> + </div> + </div> + <div class="mt-2"> + <label class="pl-1">BaseUrl</label> + <input class="w-full primary" v-model="v$.apiUrl.$model" :class="{'error': v$.apiUrl.$invalid }" /> + <p class="pl-1 mt-1 text-xs text-red-500"> + * 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 primary" v-model="v$.accountBasePath.$model" :class="{ 'error': v$.accountBasePath.$invalid }" /> + <p class="pl-1 mt-1 text-xs text-red-500"> + * 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 primary" v-model="v$.nostrEndpoint.$model" :class="{ 'error': v$.nostrEndpoint.$invalid }" /> + <p class="pl-1 mt-1 text-xs text-red-500"> + * This is the path to the Nostr plugin endpoint path (must start with /) + </p> + </div> + </fieldset> + <div class="flex justify-end mt-2"> + <button :disabled="!modified || waiting" class="rounded btn sm" :class="{'primary':modified}" @click="onSave">Save</button> + </div> + </div> + </form> + </div> +</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 { useToggle, watchDebounced } from '@vueuse/core'; +import { maxLength, helpers, required } from '@vuelidate/validators' +import { clone, isNil } from 'lodash'; +import{ Switch } from '@headlessui/vue' +import useVuelidate from '@vuelidate/core' + +const { waiting } = useWait(); +const form = useFormToaster(); +const { getSiteConfig, saveSiteConfig } = useManagment(); + +const { apply, data, buffer, modified } = useDataBuffer({ + apiUrl: '', + accountBasePath: '', + nostrEndpoint:'', + heartbeat:false, + autoInject:true, +}) + +const url = (val : string) => /^https?:\/\/[a-zA-Z0-9\.\:\/-]+$/.test(val); +const path = (val : string) => /^\/[a-zA-Z0-9-_]+$/.test(val); + +const vRules = { + apiUrl: { + required:helpers.withMessage('Base url is required', required), + maxLength: helpers.withMessage('Base url must be less than 100 characters', maxLength(100)), + url: helpers.withMessage('You must input a valid url', url) + }, + accountBasePath: { + required:helpers.withMessage('Account path is required', required), + maxLength: maxLength(50), + alphaNum: helpers.withMessage('Account path is not a valid endpoint path that begins with /', path) + }, + nostrEndpoint:{ + required: helpers.withMessage('Nostr path is required', required), + maxLength: maxLength(50), + 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 editMode = ref(false); +const toggleEdit = useToggle(editMode); + +const autoInject = computed(() => buffer.autoInject) + +const onSave = async () => { + + //Validate + const result = await validate(); + if(!result){ + return; + } + + //Test connection to the server + if(await testConnection() !== true){ + return; + } + + form.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(); + + //disable dit + toggleEdit(); +} + +const publishConfig = async () =>{ + const c = clone(buffer); + await saveSiteConfig(c); + await loadConfig(); +} + +const testConnection = async () =>{ + return await apiCall(async ({axios, toaster}) =>{ + try{ + await axios.get(`${buffer.apiUrl}`); + toaster.general.success({ + title: 'Success', + text: 'Succcesfully connected to the vault server' + }); + 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}` + }); + } + }) +} + +const loadConfig = async () => { + const config = await getSiteConfig(); + apply(config); + + //Watch for changes to autoinject value and publish changes when it does + watchDebounced(autoInject, publishConfig, { debounce: 500 }) +} + +//If edit mode is toggled off, reload config +watch(editMode, v => v ? null : loadConfig()); + + +loadConfig(); + +</script> + +<style lang="scss"> + +</style>
\ No newline at end of file diff --git a/extension/src/entries/options/index.html b/extension/src/entries/options/index.html new file mode 100644 index 0000000..72f2de7 --- /dev/null +++ b/extension/src/entries/options/index.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html lang="en" class="flex" style="min-height: 100vh; min-width: 100vw;"> + <head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Nostr Vault</title> + <style> + body.dark{ + @apply bg-dark-900; + } + body{ + @apply bg-gray-50; + } + </style> + </head> + <body class="w-full"> + <div id="app"></div> + <script type="module" src="./main.js"></script> + </body> +</html> diff --git a/extension/src/entries/options/main.js b/extension/src/entries/options/main.js new file mode 100644 index 0000000..92a4868 --- /dev/null +++ b/extension/src/entries/options/main.js @@ -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/>. + + +import { createApp } from "vue"; +import App from "./App.vue"; +import '@fontsource/noto-sans-masaram-gondi' +import "~/assets/tailwind.scss"; +import Notifications from "@kyvg/vue3-notification"; + +/* FONT AWESOME CONFIG */ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronLeft, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMoon, faSun, faTrash } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' + +library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload) + +createApp(App) + .use(Notifications) + .component('fa-icon', FontAwesomeIcon) + .mount("#app");
\ No newline at end of file diff --git a/extension/src/entries/popup/App.vue b/extension/src/entries/popup/App.vue new file mode 100644 index 0000000..0181bbb --- /dev/null +++ b/extension/src/entries/popup/App.vue @@ -0,0 +1,22 @@ +<template> + <main> + <PageContent /> + </main> +</template> + +<script setup lang="ts"> +import PageContent from "./components/PageContent.vue"; + +</script> + +<style lang="scss"> +main { + font-family: Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-align: center; + color: #2c3e50; + + @apply dark:bg-dark-800 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 new file mode 100644 index 0000000..d95dedb --- /dev/null +++ b/extension/src/entries/popup/Components/IdentitySelection.vue @@ -0,0 +1,46 @@ +<template> + <div class="px-3 text-left"> + <div class="w-full"> + <div class=""> + <select class="w-full primary" + :disabled="waiting" + :value="selected?.Id" + @change.prevent="onSelected" + > + <option disabled value="">Select an identity</option> + <option v-for="key in allKeys" :value="key.Id">{{ key.UserName }}</option> + </select> + </div> + </div> + + </div> +</template> + +<script setup lang="ts"> +import { find } from 'lodash' +import { computed } from "vue"; +import { useStatus, useManagment, NostrPubKey } from "~/bg-api/popup.ts"; +import { useWait } from '@vnuge/vnlib.browser' +import { computedAsync } from '@vueuse/core'; + +const { selectedKey } = useStatus(); +const { waiting } = useWait(); +const { getAllKeys, selectKey } = useManagment(); + +const allKeys = computedAsync<NostrPubKey[]>(async () => await getAllKeys(), []); + +const onSelected = async ({target}) =>{ + //Select the key of the given id + const selected = find(allKeys.value, {Id: target.value}) + if(selected){ + await selectKey(selected) + } +} + +const selected = computed(() => selectedKey?.value || { Id:"0" }) + +</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 new file mode 100644 index 0000000..495b64e --- /dev/null +++ b/extension/src/entries/popup/Components/Login.vue @@ -0,0 +1,40 @@ +<template> + <div id="login-template" class="py-4"> + <form class="" @submit.prevent="onSubmit"> + <fieldset class="px-4 input-container"> + <label class="">Please enter your authentication token</label> + <textarea class="w-full primary" v-model="token" rows="5"> + </textarea> + </fieldset> + <div class="flex justify-end mt-2"> + <div class="px-3"> + <button class="w-24 rounded btn sm primary"> + <fa-icon v-if="waiting" icon="spinner" class="animate-spin" /> + <span v-else>Submit</span> + </button> + </div> + </div> + </form> + </div> +</template> + +<script setup lang="ts"> +import { useWait } from "@vnuge/vnlib.browser"; +import { ref } from "vue"; +import { useManagment } from "~/bg-api/popup.ts"; + +const { login } = useManagment() +const { waiting } = useWait() + +const token = ref('') + +const onSubmit = async () => { + //console.log(token.value) + await login(token.value) +} + +</script> + +<style lang="scss"> + +</style>
\ No newline at end of file diff --git a/extension/src/entries/popup/Components/PageContent.vue b/extension/src/entries/popup/Components/PageContent.vue new file mode 100644 index 0000000..c9b2d5f --- /dev/null +++ b/extension/src/entries/popup/Components/PageContent.vue @@ -0,0 +1,104 @@ +<template> + <div + id="injected-root" + 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 font-mono text-sm"> + A nostr credential vault + </div> + <div class="my-auto" v-if="loggedIn"> + <button class="rounded btn sm red" @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"> + <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"> + <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="text-sm break-all"> + {{ pubKey ?? 'No key selected' }} + </div> + <div class="my-auto ml-auto cursor-pointer" :class="{'text-primary-500': copied }"> + <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> + </div> + </div> + </div> + + <notifications class="toaster" group="form" position="top-right" /> + + </div> +</template> + +<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 { notify } from "@kyvg/vue3-notification"; +import { runtime } from "webextension-polyfill"; +import Login from "./Login.vue"; +import IdentitySelection from "./IdentitySelection.vue"; + +configureNotifier({notify, close:notify.close}) + +const { loggedIn, userName, selectedKey, darkMode } = useStatus() +const { logout, getProfile, getSiteConfig } = useManagment() + +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 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; +} + +</style> diff --git a/extension/src/entries/popup/index.html b/extension/src/entries/popup/index.html new file mode 100644 index 0000000..8ffe33b --- /dev/null +++ b/extension/src/entries/popup/index.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <title>Popup</title> + </head> + <body style="min-width: 100px"> + <div id="app"></div> + <script type="module" src="./main.js"></script> + </body> +</html> diff --git a/extension/src/entries/popup/main.js b/extension/src/entries/popup/main.js new file mode 100644 index 0000000..d9101ab --- /dev/null +++ b/extension/src/entries/popup/main.js @@ -0,0 +1,32 @@ +// 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 { createApp } from "vue"; +import App from "./App.vue"; +import Notifications from "@kyvg/vue3-notification"; +import '@fontsource/noto-sans-masaram-gondi' +import "~/assets/tailwind.scss"; + +/* FONT AWESOME CONFIG */ +import { library } from '@fortawesome/fontawesome-svg-core' +import { faArrowRightFromBracket, faCopy, faEdit, faGear, faSpinner } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' + +library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket) + +createApp(App) + .use(Notifications) + .component('fa-icon', FontAwesomeIcon) + .mount("#app"); diff --git a/extension/src/manifest.js b/extension/src/manifest.js new file mode 100644 index 0000000..19d51b1 --- /dev/null +++ b/extension/src/manifest.js @@ -0,0 +1,113 @@ +// 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 pkg from "../package.json"; + +const sharedManifest = { + content_scripts: [ + { + js: ["src/entries/contentScript/primary/main.js", "src/entries/contentScript/nostr-shim.js"], + matches: ["*://*/*"] + }, + ], + icons: { + 16: "icons/16.png", + 32: "icons/32.png", + 38: "icons/38.png", + 48: "icons/48.png", + 72: "icons/72.png", + 96: "icons/96.png", + }, + options_ui: { + page: "src/entries/options/index.html", + open_in_tab: true, + browser_style:false + }, + permissions: [ + 'storage' + ], + + + browser_specific_settings: { + "gecko": { + "id": "{fdacee2c-bab4-490d-bc4b-ecdd03d5d68a}" + } + }, + + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';" +}; + +const browserAction = { + default_icon: { + 16: "icons/16.png", + 19: "icons/19.png", + 32: "icons/32.png", + 38: "icons/38.png", + }, + default_popup: "src/entries/popup/index.html", +}; + +const ManifestV2 = { + ...sharedManifest, + background: { + scripts: ["src/entries/background/script.js"], + persistent: true, + }, + browser_action: browserAction, + options_ui: { + ...sharedManifest.options_ui, + chrome_style: false, + }, + permissions: [...sharedManifest.permissions, "*://*/*"], +}; + +const ManifestV3 = { + ...sharedManifest, + action: browserAction, + background: { + service_worker: "src/entries/background/serviceWorker.js", + }, + host_permissions: ["*://*/*"], +}; + +export function getManifest(manifestVersion) { + const manifest = { + author: pkg.author, + description: pkg.description, + name: pkg.displayName ?? pkg.name, + version: pkg.version, + }; + + if (manifestVersion === 2) { + return { + ...manifest, + ...ManifestV2, + manifest_version: manifestVersion, + }; + } + + if (manifestVersion === 3) { + return { + ...manifest, + ...ManifestV3, + manifest_version: manifestVersion, + }; + } + + throw new Error( + `Missing manifest definition for manifestVersion ${manifestVersion}` + ); +} |