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