aboutsummaryrefslogtreecommitdiff
path: root/extension/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'extension/src/features')
-rw-r--r--extension/src/features/account-api.ts190
-rw-r--r--extension/src/features/auth-api.ts123
-rw-r--r--extension/src/features/framework/index.ts214
-rw-r--r--extension/src/features/history.ts42
-rw-r--r--extension/src/features/identity-api.ts112
-rw-r--r--extension/src/features/index.ts33
-rw-r--r--extension/src/features/nip07allow-api.ts181
-rw-r--r--extension/src/features/nostr-api.ts79
-rw-r--r--extension/src/features/permissions.ts80
-rw-r--r--extension/src/features/server-api/endpoints.ts70
-rw-r--r--extension/src/features/server-api/index.ts137
-rw-r--r--extension/src/features/settings.ts165
-rw-r--r--extension/src/features/tagfilter-api.ts125
-rw-r--r--extension/src/features/types.ts147
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