diff --git a/package-lock.json b/package-lock.json
index b4d8205..7bc19cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,16 +16,22 @@
"@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7",
+ "crypto-hash": "3.0.0",
+ "file-saver": "2.0.5",
+ "jszip": "3.10.1",
"lodash": "4.17.21",
+ "mui-file-input": "4.0.4",
"nostr-tools": "2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "9.1.0",
"react-router-dom": "6.22.1",
"react-toastify": "10.0.4",
- "redux": "5.0.1"
+ "redux": "5.0.1",
+ "tseep": "1.2.1"
},
"devDependencies": {
+ "@types/file-saver": "2.0.7",
"@types/lodash": "4.14.202",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
@@ -1938,6 +1944,12 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true
},
+ "node_modules/@types/file-saver": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
+ "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
+ "dev": true
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2567,6 +2579,11 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+ },
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -2602,6 +2619,17 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-hash/-/crypto-hash-3.0.0.tgz",
+ "integrity": "sha512-5l5xGtzuvGTU28GXxGV1JYVFou68buZWpkV1Fx5hIDRPnfbQ8KzabTlNIuDIeSCYGVPFehupzDqlnbXm2IXmdQ==",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3211,6 +3239,11 @@
"node": "^10.12.0 || >=12.0.0"
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -3474,6 +3507,11 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+ },
"node_modules/immer": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz",
@@ -3526,8 +3564,7 @@
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-arrayish": {
"version": "0.2.1",
@@ -3601,6 +3638,11 @@
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3671,6 +3713,17 @@
"node": ">=6"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3693,6 +3746,14 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/light-bolt11-decoder": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.0.0.tgz",
@@ -3819,6 +3880,27 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/mui-file-input": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/mui-file-input/-/mui-file-input-4.0.4.tgz",
+ "integrity": "sha512-WYzPqKg4lahGyuUIt7674vwbgW6WS1CO066ujuvCwcvr7mw7IDPPhCCJ/rD6i36OhVN2f+1hlTMX5dmsCyYxrQ==",
+ "dependencies": {
+ "pretty-bytes": "^6.1.1"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@mui/material": "^5.0.0",
+ "@types/react": "^18.0.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@@ -4002,6 +4084,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4144,6 +4231,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-bytes": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+ "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
+ "engines": {
+ "node": "^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -4313,6 +4416,20 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -4452,6 +4569,11 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
"node_modules/sass": {
"version": "1.71.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz",
@@ -4510,6 +4632,11 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4557,6 +4684,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -4839,6 +4974,11 @@
"node": ">=8"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
diff --git a/package.json b/package.json
index 233655e..166221f 100644
--- a/package.json
+++ b/package.json
@@ -18,16 +18,22 @@
"@nostr-dev-kit/ndk": "2.5.0",
"@reduxjs/toolkit": "2.2.1",
"axios": "1.6.7",
+ "crypto-hash": "3.0.0",
+ "file-saver": "2.0.5",
+ "jszip": "3.10.1",
"lodash": "4.17.21",
+ "mui-file-input": "4.0.4",
"nostr-tools": "2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "9.1.0",
"react-router-dom": "6.22.1",
"react-toastify": "10.0.4",
- "redux": "5.0.1"
+ "redux": "5.0.1",
+ "tseep": "1.2.1"
},
"devDependencies": {
+ "@types/file-saver": "2.0.7",
"@types/lodash": "4.14.202",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
diff --git a/src/App.tsx b/src/App.tsx
index b0bf01c..6b2a50d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,10 +1,14 @@
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
-import { Route, Routes } from 'react-router-dom'
+import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthController, NostrController } from './controllers'
import { MainLayout } from './layouts/Main'
-import { LandingPage } from './pages/landing/LandingPage'
-import { privateRoutes, publicRoutes } from './routes'
+import {
+ appPrivateRoutes,
+ appPublicRoutes,
+ privateRoutes,
+ publicRoutes
+} from './routes'
import { State } from './store/rootReducer'
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
@@ -32,7 +36,6 @@ const App = () => {
return (
}>
- {authState?.loggedIn && } />}
{authState?.loggedIn &&
privateRoutes.map((route, index) => (
{
)
}
})}
- {!authState ||
- (!authState.loggedIn && } />)}
+
+
+ }
+ />
)
diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx
index 8494716..56f5495 100644
--- a/src/components/AppBar/AppBar.tsx
+++ b/src/components/AppBar/AppBar.tsx
@@ -1,12 +1,17 @@
+import { Menu as MenuIcon } from '@mui/icons-material'
import {
AppBar as AppBarMui,
Box,
Button,
+ IconButton,
Menu,
MenuItem,
+ Tab,
+ Tabs,
Toolbar,
Typography
} from '@mui/material'
+
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { setAuthState } from '../../store/actions'
@@ -14,11 +19,15 @@ import { State } from '../../store/rootReducer'
import { Dispatch } from '../../store/store'
import Username from '../username'
-import { Link, useNavigate } from 'react-router-dom'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
import nostrichAvatar from '../../assets/images/avatar.png'
import nostrichLogo from '../../assets/images/nostr-logo.jpg'
import { NostrController } from '../../controllers'
-import { appPublicRoutes, getProfileRoute } from '../../routes'
+import {
+ appPrivateRoutes,
+ appPublicRoutes,
+ getProfileRoute
+} from '../../routes'
import {
clearAuthToken,
saveNsecBunkerDelegatedKey,
@@ -26,13 +35,20 @@ import {
} from '../../utils'
import styles from './style.module.scss'
+const validTabs = [appPrivateRoutes.homePage, appPrivateRoutes.decryptZip]
+
export const AppBar = () => {
const navigate = useNavigate()
+ const { pathname } = useLocation()
+ const [tabValue, setTabValue] = useState(
+ validTabs.includes(pathname) ? pathname : '/'
+ )
const dispatch: Dispatch = useDispatch()
const [username, setUsername] = useState('')
const [userAvatar, setUserAvatar] = useState(nostrichAvatar)
const [anchorElUser, setAnchorElUser] = useState(null)
+ const [anchorElNav, setAnchorElNav] = useState(null)
const authState = useSelector((state: State) => state.auth)
const metadataState = useSelector((state: State) => state.metadata)
@@ -51,10 +67,18 @@ export const AppBar = () => {
setAnchorElUser(event.currentTarget)
}
+ const handleOpenNavMenu = (event: React.MouseEvent) => {
+ setAnchorElNav(event.currentTarget)
+ }
+
const handleCloseUserMenu = () => {
setAnchorElUser(null)
}
+ const handleCloseNavMenu = () => {
+ setAnchorElNav(null)
+ }
+
const handleProfile = () => {
const hexKey = authState?.usersPubkey
if (hexKey) navigate(getProfileRoute(hexKey))
@@ -63,6 +87,7 @@ export const AppBar = () => {
}
const handleLogout = () => {
+ handleCloseUserMenu()
dispatch(
setAuthState({
loggedIn: false,
@@ -87,8 +112,98 @@ export const AppBar = () => {
return (
-
- navigate('/')} />
+
+
+ navigate('/')} />
+
+
+ {isAuthenticated && (
+ setTabValue(value)}
+ >
+
+
+
+ )}
+
+
+
+ {!isAuthenticated && (
+
+ navigate('/')}
+ />
+
+ )}
+
+ {isAuthenticated && (
+ <>
+
+
+
+
+
+ >
+ )}
diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts
index 5b3945b..e9e34d4 100644
--- a/src/controllers/NostrController.ts
+++ b/src/controllers/NostrController.ts
@@ -1,7 +1,9 @@
+import { EventEmitter } from 'tseep'
import NDK, {
NDKEvent,
NDKNip46Signer,
NDKPrivateKeySigner,
+ NDKUser,
NostrEvent
} from '@nostr-dev-kit/ndk'
import {
@@ -10,6 +12,7 @@ import {
Relay,
UnsignedEvent,
finalizeEvent,
+ nip04,
nip19
} from 'nostr-tools'
import { updateNsecbunkerPubkey } from '../store/actions'
@@ -18,13 +21,23 @@ import store from '../store/store'
import { SignedEvent } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
-export class NostrController {
+export class NostrController extends EventEmitter {
private static instance: NostrController
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
- private constructor() {}
+ private constructor() {
+ super()
+ }
+
+ private getNostrObject = () => {
+ if (window.nostr) return window.nostr
+
+ throw new Error(
+ `window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
+ )
+ }
public nsecBunkerInit = async (relays: string[]) => {
// Don't reinstantiate bunker NDK if exists with same relays
@@ -248,19 +261,13 @@ export class NostrController {
return Promise.resolve(signedEvent)
} else if (loginMethod === LoginMethods.extension) {
- if (!window.nostr) {
- return Promise.reject(
- `Login method is ${loginMethod} but window.nostr is not present. Make sure extension is working properly`
- )
- }
+ const nostr = this.getNostrObject()
- return (await window.nostr
- .signEvent(event as NostrEvent)
- .catch((err: any) => {
- console.log('Error while signing event: ', err)
+ return (await nostr.signEvent(event as NostrEvent).catch((err: any) => {
+ console.log('Error while signing event: ', err)
- throw err
- })) as Event
+ throw err
+ })) as Event
} else {
return Promise.reject(
`We could not sign the event, none of the signing methods are available`
@@ -268,26 +275,66 @@ export class NostrController {
}
}
+ nip04Encrypt = async (receiver: string, content: string) => {
+ const loginMethod = (store.getState().auth as AuthState).loginMethod
+
+ if (loginMethod === LoginMethods.extension) {
+ const nostr = this.getNostrObject()
+
+ if (!nostr.nip04) {
+ throw new Error(
+ `Your nostr extension does not support nip04 encryption & decryption`
+ )
+ }
+
+ const encrypted = await nostr.nip04.encrypt(receiver, content)
+ return encrypted
+ }
+
+ if (loginMethod === LoginMethods.privateKey) {
+ const keyPair = (store.getState().auth as AuthState).keyPair
+
+ if (!keyPair) {
+ throw new Error(
+ `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
+ )
+ }
+
+ const encrypted = await nip04.encrypt(keyPair.private, receiver, content)
+ return encrypted
+ }
+
+ if (loginMethod === LoginMethods.nsecBunker) {
+ const user = new NDKUser({ pubkey: receiver })
+
+ this.remoteSigner?.on('authUrl', (authUrl) => {
+ this.emit('nsecbunker-auth', authUrl)
+ })
+
+ if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
+ const encrypted = await this.remoteSigner.encrypt(user, content)
+
+ return encrypted
+ }
+
+ throw new Error('Login method is undefined')
+ }
+
/**
* Function will capture the public key from the nostr extension or if no extension present
* function wil capture the public key from the local storage
*/
capturePublicKey = async (): Promise => {
- if (window.nostr) {
- const pubKey = await window.nostr.getPublicKey().catch((err: any) => {
- return Promise.reject(err.message)
- })
+ const nostr = this.getNostrObject()
+ const pubKey = await nostr.getPublicKey().catch((err: any) => {
+ return Promise.reject(err.message)
+ })
- if (!pubKey) {
- return Promise.reject('Error getting public key, user canceled')
- }
-
- return Promise.resolve(pubKey)
+ if (!pubKey) {
+ return Promise.reject('Error getting public key, user canceled')
}
- return Promise.reject(
- 'window.nostr object not present. Make sure you have an nostr extension installed.'
- )
+ return Promise.resolve(pubKey)
}
/**
diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx
index f356b63..8d1bd81 100644
--- a/src/layouts/Main.tsx
+++ b/src/layouts/Main.tsx
@@ -1,20 +1,66 @@
import { Box } from '@mui/material'
import Container from '@mui/material/Container'
-import { useEffect } from 'react'
+import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { Outlet } from 'react-router-dom'
import { AppBar } from '../components/AppBar/AppBar'
-import { restoreState } from '../store/actions'
-import { loadState } from '../utils'
+import { restoreState, setAuthState } from '../store/actions'
+import { clearAuthToken, loadState, saveNsecBunkerDelegatedKey } from '../utils'
+import { LoadingSpinner } from '../components/LoadingSpinner'
+import { Dispatch } from '../store/store'
+import { NostrController } from '../controllers'
+import { LoginMethods } from '../store/auth/types'
export const MainLayout = () => {
- const dispatch = useDispatch()
+ const dispatch: Dispatch = useDispatch()
+ const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
+ const logout = () => {
+ dispatch(
+ setAuthState({
+ loggedIn: false,
+ usersPubkey: undefined,
+ loginMethod: undefined,
+ nsecBunkerPubkey: undefined
+ })
+ )
+
+ // clear authToken saved in local storage
+ clearAuthToken()
+
+ // update nsecBunker delegated key
+ const newDelegatedKey =
+ NostrController.getInstance().generateDelegatedKey()
+ saveNsecBunkerDelegatedKey(newDelegatedKey)
+ }
+
const restoredState = loadState()
- if (restoredState) dispatch(restoreState(restoredState))
+ if (restoredState) {
+ dispatch(restoreState(restoredState))
+
+ const { loggedIn, loginMethod, usersPubkey, nsecBunkerRelays } =
+ restoredState.auth
+
+ if (loggedIn) {
+ if (!loginMethod || !usersPubkey) return logout()
+
+ if (loginMethod === LoginMethods.nsecBunker) {
+ if (!nsecBunkerRelays) return logout()
+
+ const nostrController = NostrController.getInstance()
+ nostrController.nsecBunkerInit(nsecBunkerRelays).then(() => {
+ nostrController.createNsecBunkerSigner(usersPubkey)
+ })
+ }
+ }
+ }
+
+ setIsLoading(false)
}, [dispatch])
+ if (isLoading) return
+
return (
<>
@@ -22,7 +68,10 @@ export const MainLayout = () => {
diff --git a/src/pages/decrypt/index.tsx b/src/pages/decrypt/index.tsx
new file mode 100644
index 0000000..af3c2f5
--- /dev/null
+++ b/src/pages/decrypt/index.tsx
@@ -0,0 +1,93 @@
+import { Box, Button, TextField, Typography } from '@mui/material'
+import saveAs from 'file-saver'
+import { MuiFileInput } from 'mui-file-input'
+import { useState } from 'react'
+import { LoadingSpinner } from '../../components/LoadingSpinner'
+import { decryptArrayBuffer } from '../../utils'
+import styles from './style.module.scss'
+
+export const DecryptZip = () => {
+ const [selectedFile, setSelectedFile] = useState(null)
+ const [encryptionKey, setEncryptionKey] = useState('')
+
+ const [isLoading, setIsLoading] = useState(false)
+ const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
+ const [isDraggingOver, setIsDraggingOver] = useState(false)
+
+ const handleDecrypt = async () => {
+ if (!selectedFile || !encryptionKey) return
+
+ setIsLoading(true)
+ setLoadingSpinnerDesc('Decrypting zip file')
+
+ const encryptedArrayBuffer = await selectedFile.arrayBuffer()
+
+ const arrayBuffer = await decryptArrayBuffer(
+ encryptedArrayBuffer,
+ encryptionKey
+ )
+
+ const blob = new Blob([arrayBuffer])
+ saveAs(blob, 'decrypted.zip')
+
+ setIsLoading(false)
+ }
+
+ const handleDrop = (event: React.DragEvent) => {
+ event.preventDefault()
+ setIsDraggingOver(false)
+ const file = event.dataTransfer.files[0]
+ if (file.type === 'application/zip') setSelectedFile(file)
+ }
+
+ const handleDragOver = (event: React.DragEvent) => {
+ event.preventDefault()
+ setIsDraggingOver(true)
+ }
+
+ return (
+ <>
+ {isLoading && }
+
+
+ Select encrypted zip file
+
+
+
+ {isDraggingOver && (
+
+ Drop file here
+
+ )}
+ setSelectedFile(value)}
+ InputProps={{
+ inputProps: {
+ accept: '.zip'
+ }
+ }}
+ />
+
+ setEncryptionKey(e.target.value)}
+ />
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/pages/decrypt/style.module.scss b/src/pages/decrypt/style.module.scss
new file mode 100644
index 0000000..ae72e77
--- /dev/null
+++ b/src/pages/decrypt/style.module.scss
@@ -0,0 +1,27 @@
+@import '../../colors.scss';
+
+.container {
+ display: flex;
+ flex-direction: column;
+ color: $text-color;
+
+ .inputBlock {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ }
+
+ .fileDragOver {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(255, 255, 255, 0.8);
+ z-index: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+}
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
new file mode 100644
index 0000000..efdbb3c
--- /dev/null
+++ b/src/pages/home/index.tsx
@@ -0,0 +1,589 @@
+import {
+ Box,
+ Button,
+ FormControl,
+ IconButton,
+ InputLabel,
+ List,
+ ListItem,
+ ListSubheader,
+ MenuItem,
+ Select,
+ TextField,
+ Typography
+} from '@mui/material'
+import { MuiFileInput } from 'mui-file-input'
+import styles from './style.module.scss'
+import { Dispatch, SetStateAction, useEffect, useState } from 'react'
+import placeholderAvatar from '../../assets/images/nostr-logo.jpg'
+import { ProfileMetadata } from '../../types'
+import { MetadataController, NostrController } from '../../controllers'
+import { Link } from 'react-router-dom'
+import {
+ encryptArrayBuffer,
+ generateEncryptionKey,
+ getFileHash,
+ pubToHex,
+ queryNip05,
+ shorten
+} from '../../utils'
+import { LoadingSpinner } from '../../components/LoadingSpinner'
+import { getProfileRoute } from '../../routes'
+import { Clear } from '@mui/icons-material'
+import JSZip from 'jszip'
+import { toast } from 'react-toastify'
+import { useSelector } from 'react-redux'
+import { State } from '../../store/rootReducer'
+import { EventTemplate } from 'nostr-tools'
+import axios from 'axios'
+
+enum SelectionType {
+ signer = 'Signer',
+ viewer = 'Viewer'
+}
+
+type MetadataMap = {
+ [key: string]: ProfileMetadata
+}
+
+export const HomePage = () => {
+ const [inputValue, setInputValue] = useState('')
+ const [type, setType] = useState(SelectionType.signer)
+ const [error, setError] = useState()
+
+ const [signers, setSigners] = useState([])
+ const [viewers, setViewers] = useState([])
+
+ const [metadataMap, setMetadataMap] = useState({})
+
+ const [selectedFiles, setSelectedFiles] = useState([])
+
+ const [isLoading, setIsLoading] = useState(false)
+ const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
+ const [authUrl, setAuthUrl] = useState()
+
+ const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
+
+ const nostrController = NostrController.getInstance()
+
+ const handleAddClick = async () => {
+ setError(undefined)
+
+ const addPubkey = (pubkey: string) => {
+ const addElement = (prev: string[]) => {
+ // if key is already in the list just return that
+ if (prev.includes(pubkey)) return prev
+
+ return [...prev, pubkey]
+ }
+ if (type === SelectionType.signer) {
+ setSigners(addElement)
+ } else {
+ setViewers(addElement)
+ }
+ }
+
+ if (inputValue.startsWith('npub')) {
+ const pubkey = await pubToHex(inputValue)
+ if (pubkey) {
+ addPubkey(pubkey)
+ setInputValue('')
+ } else {
+ setError('Provided npub is not valid. Please enter correct npub.')
+ }
+ return
+ }
+
+ if (inputValue.includes('@')) {
+ setIsLoading(true)
+ setLoadingSpinnerDesc('Querying for nip05')
+ const nip05Profile = await queryNip05(inputValue)
+ .catch((err) => {
+ console.error(`error occurred in querying nip05: ${inputValue}`, err)
+ return null
+ })
+ .finally(() => {
+ setIsLoading(false)
+ setLoadingSpinnerDesc('')
+ })
+
+ if (nip05Profile) {
+ const pubkey = nip05Profile.pubkey
+ addPubkey(pubkey)
+ setInputValue('')
+ } else {
+ setError('Provided nip05 is not valid. Please enter correct nip05.')
+ }
+ return
+ }
+
+ setError('Invalid input! Make sure to provide correct npub or nip05.')
+ }
+
+ const handleRemove = (pubkey: string, selectionType: SelectionType) => {
+ if (selectionType === SelectionType.signer) {
+ setSigners((prev) => prev.filter((signer) => signer !== pubkey))
+ } else {
+ setViewers((prev) => prev.filter((viewer) => viewer !== pubkey))
+ }
+ }
+
+ const handleSelectFiles = (files: File[]) => {
+ setSelectedFiles((prev) => {
+ const prevFileNames = prev.map((file) => file.name)
+
+ const newFiles = files.filter(
+ (file) => !prevFileNames.includes(file.name)
+ )
+
+ return [...prev, ...newFiles]
+ })
+ }
+
+ const handleRemoveFile = (fileToRemove: File) => {
+ setSelectedFiles((prevFiles) =>
+ prevFiles.filter((file) => file.name !== fileToRemove.name)
+ )
+ }
+
+ const handleSubmit = async () => {
+ if (signers.length === 0) {
+ toast.error('No signer is provided. At least provide one signer.')
+ return
+ }
+
+ if (viewers.length === 0) {
+ toast.error('No viewer is provided. At least provide one viewer.')
+ return
+ }
+
+ if (selectedFiles.length === 0) {
+ toast.error('No file is provided. At least provide one file.')
+ return
+ }
+
+ setIsLoading(true)
+ setLoadingSpinnerDesc('Generating hashes for files')
+
+ const fileHashes: { [key: string]: string } = {}
+
+ for (const file of selectedFiles) {
+ const hash = await getFileHash(file)
+
+ fileHashes[file.name] = hash
+ }
+
+ const zip = new JSZip()
+
+ selectedFiles.forEach((file) => {
+ zip.file(`files/${file.name}`, file)
+ })
+
+ const event: EventTemplate = {
+ kind: 1,
+ tags: [['r', signers[0]]],
+ content: JSON.stringify(fileHashes),
+ created_at: Math.floor(Date.now() / 1000)
+ }
+
+ setLoadingSpinnerDesc('Signing nostr event')
+ const signedEvent = await nostrController.signEvent(event).catch((err) => {
+ console.error(err)
+ toast.error(err.message || 'Error occurred in signing nostr event')
+ setIsLoading(false)
+ return null
+ })
+
+ if (!signedEvent) return
+
+ const meta = {
+ signers,
+ viewers,
+ fileHashes,
+ submittedBy: usersPubkey,
+ signedEvents: {
+ [signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2)
+ }
+ }
+
+ try {
+ const stringifiedMeta = JSON.stringify(meta, null, 2)
+
+ zip.file('meta.json', stringifiedMeta)
+ } catch (err) {
+ toast.error('An error occurred in converting meta json to string')
+ }
+
+ setLoadingSpinnerDesc('Generating zip file')
+
+ const arraybuffer = await zip
+ .generateAsync({
+ type: 'arraybuffer',
+ compression: 'DEFLATE',
+ compressionOptions: {
+ level: 6
+ }
+ })
+ .catch((err) => {
+ console.log('err in zip:>> ', err)
+ setIsLoading(false)
+ toast.error(err.message || 'Error occurred in generating zip file')
+ return null
+ })
+
+ if (!arraybuffer) return
+
+ const encryptionKey = await generateEncryptionKey()
+
+ setLoadingSpinnerDesc('Encrypting zip file')
+ const encryptedArrayBuffer = await encryptArrayBuffer(
+ arraybuffer,
+ encryptionKey
+ )
+
+ const blob = new Blob([encryptedArrayBuffer])
+
+ setLoadingSpinnerDesc('Uploading zip file to file storage.')
+ const fileUrl = await uploadToFileStorage(blob)
+ .then((url) => {
+ toast.success('zip file uploaded to file storage')
+ return url
+ })
+ .catch((err) => {
+ console.log('err in upload:>> ', err)
+ toast.error(err.message || 'Error occurred in uploading zip file')
+ return null
+ })
+
+ if (!fileUrl) return
+
+ await sendDMToFirstSigner(fileUrl, encryptionKey, signers[0])
+
+ setIsLoading(false)
+ }
+
+ const uploadToFileStorage = async (blob: Blob) => {
+ const unixNow = Math.floor(Date.now() / 1000)
+
+ const file = new File([blob], `zipped-${unixNow}.zip`, {
+ type: 'application/zip'
+ })
+
+ const event: EventTemplate = {
+ kind: 24242,
+ content: 'Authorize Upload',
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ['t', 'upload'],
+ ['expiration', String(unixNow + 60 * 5)],
+ ['name', file.name],
+ ['size', String(file.size)]
+ ]
+ }
+
+ setLoadingSpinnerDesc('Signing auth event for uploading zip')
+ const authEvent = await nostrController.signEvent(event)
+
+ const FILE_STORAGE_URL = 'https://blossom.sigit.io'
+
+ const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
+ headers: {
+ Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)),
+ 'Content-Type': 'application/zip'
+ }
+ })
+
+ return response.data.url as string
+ }
+
+ const sendDMToFirstSigner = async (
+ fileUrl: string,
+ encryptionKey: string,
+ pubkey: string
+ ) => {
+ const content = `You have been requested for a signature.\nHere is the url for zip file that you can download.\n
+ ${fileUrl}\nHowever this zip file is encrypted and you need to decrypt it using https://app.sigit.io\n Encryption key: ${encryptionKey}`
+
+ nostrController.on('nsecbunker-auth', (url) => {
+ setAuthUrl(url)
+ })
+
+ setLoadingSpinnerDesc('encrypting content for DM')
+
+ // todo: add timeout
+ const encrypted = await nostrController
+ .nip04Encrypt(pubkey, content)
+ .then((res) => {
+ return res
+ })
+ .catch((err) => {
+ console.log('err :>> ', err)
+ toast.error(
+ err.message || 'An error occurred while encrypting DM content'
+ )
+ return null
+ })
+ .finally(() => {
+ setAuthUrl(undefined)
+ })
+
+ if (!encrypted) return
+
+ const event: EventTemplate = {
+ kind: 4,
+ content: encrypted,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['p', signers[0]]]
+ }
+
+ setLoadingSpinnerDesc('signing event for DM')
+ const signedEvent = await nostrController.signEvent(event).catch((err) => {
+ console.log('err :>> ', err)
+ toast.error(err.message || 'An error occurred while signing event for DM')
+ return null
+ })
+
+ if (!signedEvent) return
+
+ // const metadata = metadataMap[pubkey]
+
+ setLoadingSpinnerDesc('Publishing encrypted DM')
+
+ // todo: do not use hardcoded relay
+ await nostrController
+ .publishEvent(signedEvent, 'wss://relayable.org')
+ .then(() => {
+ toast.success('DM sent to first signer')
+ })
+ .catch((err) => {
+ console.log('err :>> ', err)
+ toast.error(err.message || 'An error occurred while publishing DM')
+ return null
+ })
+ }
+
+ if (authUrl) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+ {isLoading && }
+
+
+ Select signers and viewers
+
+
+ setInputValue(e.target.value)}
+ helperText={error}
+ error={!!error}
+ />
+
+ Type
+
+
+
+
+
+
+
+
+
+ Select files
+
+
+ handleSelectFiles(value)}
+ />
+
+
+ {selectedFiles.map((file, index) => (
+ -
+ {file.name}
+ handleRemoveFile(file)}>
+ {' '}
+
+
+ ))}
+
+
+ {signers.length > 0 && (
+
+ Signers
+
+ }
+ >
+ {signers.map((signer, index) => (
+ handleRemove(signer, SelectionType.signer)}
+ />
+ ))}
+
+ )}
+
+ {viewers.length > 0 && (
+
+ Viewers
+
+ }
+ >
+ {viewers.map((viewer, index) => (
+ handleRemove(viewer, SelectionType.viewer)}
+ />
+ ))}
+
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+type DisplaySignerOrViewerProps = {
+ pubkey: string
+ metadataMap: MetadataMap
+ setMetadataMap: Dispatch>
+ remove: () => void
+}
+
+const DisplaySignerOrViewer = ({
+ pubkey,
+ metadataMap,
+ setMetadataMap,
+ remove
+}: DisplaySignerOrViewerProps) => {
+ const [metadata, setMetadata] = useState()
+
+ useEffect(() => {
+ const getMetadata = async (pubkey: string) => {
+ console.log('1 :>> ', 1)
+ const metadataController = new MetadataController()
+ const metadataEvent = await metadataController
+ .findMetadata(pubkey)
+ .catch((err) => {
+ console.error(
+ `error occurred in finding metadata for: ${pubkey}`,
+ err
+ )
+ return null
+ })
+
+ if (metadataEvent) {
+ const metadataContent =
+ metadataController.extractProfileMetadataContent(metadataEvent)
+ if (metadataContent) {
+ setMetadata(metadataContent)
+ setMetadataMap((prev) => ({
+ ...prev,
+ [pubkey]: metadataContent
+ }))
+ }
+ }
+ }
+
+ const existingMetadata = metadataMap[pubkey]
+
+ console.log('metadataMap :>> ', metadataMap)
+
+ if (existingMetadata) {
+ setMetadata(existingMetadata)
+ } else {
+ getMetadata(pubkey)
+ }
+ }, [pubkey, metadataMap])
+
+ const imageLoadError = (event: any) => {
+ event.target.src = placeholderAvatar
+ }
+
+ return (
+
+
+
+
+ {metadata?.display_name || metadata?.name || shorten(pubkey)}
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss
new file mode 100644
index 0000000..0c6c0d9
--- /dev/null
+++ b/src/pages/home/style.module.scss
@@ -0,0 +1,33 @@
+@import '../../colors.scss';
+
+.container {
+ display: flex;
+ flex-direction: column;
+ color: $text-color;
+
+ .inputBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ margin-top: 10px;
+ }
+}
+
+.subHeader {
+ border-bottom: 0.5px solid;
+}
+
+.listItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between !important;
+
+ .img {
+ max-width: 100px;
+ }
+
+ .name {
+ text-align: center;
+ cursor: pointer;
+ }
+}
diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx
index 6267e8c..9d85184 100644
--- a/src/pages/login/index.tsx
+++ b/src/pages/login/index.tsx
@@ -13,7 +13,8 @@ import {
import {
updateKeyPair,
updateLoginMethod,
- updateNsecbunkerPubkey
+ updateNsecbunkerPubkey,
+ updateNsecbunkerRelays
} from '../../store/actions'
import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store'
@@ -183,6 +184,7 @@ export const Login = () => {
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
+ dispatch(updateNsecbunkerRelays(relays))
setLoadingSpinnerDesc('Authenticating and finding metadata')
@@ -242,6 +244,7 @@ export const Login = () => {
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
+ dispatch(updateNsecbunkerRelays([relay]))
setLoadingSpinnerDesc('Authenticating and finding metadata')
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index ac945aa..f106abf 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -1,14 +1,18 @@
+import { DecryptZip } from '../pages/decrypt'
+import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing/LandingPage'
import { Login } from '../pages/login'
import { ProfilePage } from '../pages/profile'
import { hexToNpub } from '../utils'
export const appPrivateRoutes = {
- homePage: '/'
+ homePage: '/',
+ decryptZip: '/decrypt-zip'
}
export const appPublicRoutes = {
profile: '/profile/:npub',
+ landingPage: '/',
login: '/login',
help: 'https://help.sigit.io'
}
@@ -17,6 +21,11 @@ export const getProfileRoute = (hexKey: string) =>
appPublicRoutes.profile.replace(':npub', hexToNpub(hexKey))
export const publicRoutes = [
+ {
+ path: appPublicRoutes.landingPage,
+ hiddenWhenLoggedIn: true,
+ element:
+ },
{
path: appPublicRoutes.login,
hiddenWhenLoggedIn: true,
@@ -31,6 +40,10 @@ export const publicRoutes = [
export const privateRoutes = [
{
path: appPrivateRoutes.homePage,
- element:
+ element:
+ },
+ {
+ path: appPrivateRoutes.decryptZip,
+ element:
}
]
diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts
index f1d649f..d3047a6 100644
--- a/src/store/actionTypes.ts
+++ b/src/store/actionTypes.ts
@@ -4,5 +4,6 @@ export const SET_AUTH_STATE = 'SET_AUTH_STATE'
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
+export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
diff --git a/src/store/auth/action.ts b/src/store/auth/action.ts
index ac9e39d..bc48425 100644
--- a/src/store/auth/action.ts
+++ b/src/store/auth/action.ts
@@ -6,7 +6,8 @@ import {
SetAuthState,
UpdateKeyPair,
UpdateLoginMethod,
- UpdateNsecBunkerPubkey
+ UpdateNsecBunkerPubkey,
+ UpdateNsecBunkerRelays
} from './types'
export const setAuthState = (payload: AuthState): SetAuthState => ({
@@ -32,3 +33,10 @@ export const updateNsecbunkerPubkey = (
type: ActionTypes.UPDATE_NSECBUNKER_PUBKEY,
payload
})
+
+export const updateNsecbunkerRelays = (
+ payload: string[] | undefined
+): UpdateNsecBunkerRelays => ({
+ type: ActionTypes.UPDATE_NSECBUNKER_RELAYS,
+ payload
+})
diff --git a/src/store/auth/reducer.ts b/src/store/auth/reducer.ts
index e384076..dea4ed5 100644
--- a/src/store/auth/reducer.ts
+++ b/src/store/auth/reducer.ts
@@ -11,12 +11,13 @@ const reducer = (
): AuthState | null => {
switch (action.type) {
case ActionTypes.SET_AUTH_STATE: {
- const { loginMethod, keyPair, nsecBunkerPubkey } = state
+ const { loginMethod, keyPair, nsecBunkerPubkey, nsecBunkerRelays } = state
return {
loginMethod,
keyPair,
nsecBunkerPubkey,
+ nsecBunkerRelays,
...action.payload
}
}
@@ -47,6 +48,18 @@ const reducer = (
}
}
+ case ActionTypes.UPDATE_NSECBUNKER_RELAYS: {
+ const { payload } = action
+
+ return {
+ ...state,
+ nsecBunkerRelays: payload
+ }
+ }
+
+ case ActionTypes.RESTORE_STATE:
+ return action.payload.auth
+
default:
return state
}
diff --git a/src/store/auth/types.ts b/src/store/auth/types.ts
index 41e76be..18dafcf 100644
--- a/src/store/auth/types.ts
+++ b/src/store/auth/types.ts
@@ -1,4 +1,5 @@
import * as ActionTypes from '../actionTypes'
+import { RestoreState } from '../actions'
export enum LoginMethods {
extension = 'extension',
@@ -18,6 +19,7 @@ export interface AuthState {
loginMethod?: LoginMethods
keyPair?: Keys
nsecBunkerPubkey?: string
+ nsecBunkerRelays?: string[]
}
export interface SetAuthState {
@@ -40,8 +42,15 @@ export interface UpdateNsecBunkerPubkey {
payload: string | undefined
}
+export interface UpdateNsecBunkerRelays {
+ type: typeof ActionTypes.UPDATE_NSECBUNKER_RELAYS
+ payload: string[] | undefined
+}
+
export type AuthDispatchTypes =
+ | RestoreState
| SetAuthState
| UpdateLoginMethod
| UpdateKeyPair
| UpdateNsecBunkerPubkey
+ | UpdateNsecBunkerRelays
diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts
new file mode 100644
index 0000000..c974c29
--- /dev/null
+++ b/src/utils/crypto.ts
@@ -0,0 +1,71 @@
+import { hexToString, stringToHex } from '.'
+
+const ENCRYPTION_ALGO_NAME = 'AES-GCM'
+
+export const generateEncryptionKey = async () => {
+ const cryptoKey = await window.crypto.subtle.generateKey(
+ { name: ENCRYPTION_ALGO_NAME, length: 128 },
+ true,
+ ['encrypt', 'decrypt']
+ )
+
+ const key = await window.crypto.subtle.exportKey('jwk', cryptoKey)
+
+ const jsonString = JSON.stringify(key)
+ const hexKey = stringToHex(jsonString)
+
+ const iv = new TextDecoder().decode(
+ window.crypto.getRandomValues(new Uint8Array(16))
+ )
+
+ return `${hexKey}?iv=${iv}`
+}
+
+export const importKey = async (key: string) => {
+ const splittedKey = key.split('?iv=')
+ const keyString = hexToString(splittedKey[0])
+ const jsonWebKey = JSON.parse(keyString)
+ const iv = new TextEncoder().encode(splittedKey[1])
+
+ const cryptoKey = await window.crypto.subtle.importKey(
+ 'jwk',
+ jsonWebKey,
+ { name: ENCRYPTION_ALGO_NAME },
+ true,
+ ['encrypt', 'decrypt']
+ )
+
+ return { cryptoKey, iv }
+}
+
+export const encryptArrayBuffer = async (
+ arrayBuffer: ArrayBuffer,
+ key: string
+) => {
+ const { cryptoKey, iv } = await importKey(key)
+
+ // Encrypt the data
+ const encryptedData = await window.crypto.subtle.encrypt(
+ { name: ENCRYPTION_ALGO_NAME, iv },
+ cryptoKey,
+ arrayBuffer
+ )
+
+ return encryptedData
+}
+
+export const decryptArrayBuffer = async (
+ encryptedData: ArrayBuffer,
+ key: string
+) => {
+ const { cryptoKey, iv } = await importKey(key)
+
+ // Decrypt the data
+ const decryptedData = await window.crypto.subtle.decrypt(
+ { name: ENCRYPTION_ALGO_NAME, iv },
+ cryptoKey,
+ encryptedData
+ )
+
+ return decryptedData
+}
diff --git a/src/utils/file.ts b/src/utils/file.ts
new file mode 100644
index 0000000..a9ffa87
--- /dev/null
+++ b/src/utils/file.ts
@@ -0,0 +1,16 @@
+import { sha256 } from 'crypto-hash'
+
+export const getFileHash = (file: File) => {
+ return new Promise((resolve) => {
+ const reader = new FileReader()
+
+ reader.onload = async () => {
+ if (reader.result) {
+ const hash = await sha256(reader.result)
+ resolve(hash)
+ }
+ }
+
+ reader.readAsBinaryString(file)
+ })
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 4a57e8a..e31ec18 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,3 +1,5 @@
+export * from './crypto'
+export * from './file'
export * from './localStorage'
export * from './nostr'
export * from './string'
diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts
index 1424b88..8f074c0 100644
--- a/src/utils/nostr.ts
+++ b/src/utils/nostr.ts
@@ -1,4 +1,4 @@
-import { nip05, nip19, verifyEvent } from 'nostr-tools'
+import { nip19, verifyEvent } from 'nostr-tools'
import { SignedEvent } from '../types'
import axios from 'axios'
@@ -12,19 +12,12 @@ const validateHex = (hexKey: string) => {
/**
* NPUB provided - it will convert NPUB to HEX
- * NIP-05 provided - it will query NIP-05 profile and return HEX key if found
* HEX provided - it will return HEX
*
- * @param pubKey in NPUB, NIP-05 or HEX format
+ * @param pubKey in NPUB, HEX format
* @returns HEX format
*/
export const pubToHex = async (pubKey: string): Promise => {
- // If key is NIP-05
- if (pubKey.indexOf('@') !== -1)
- return Promise.resolve(
- (await nip05.queryProfile(pubKey).then((res) => res?.pubkey)) || null
- )
-
// If key is NPUB
if (pubKey.startsWith('npub')) {
try {
diff --git a/src/utils/string.ts b/src/utils/string.ts
index 877a142..e0e8fea 100644
--- a/src/utils/string.ts
+++ b/src/utils/string.ts
@@ -7,3 +7,33 @@ export const shorten = (str: string, offset = 9) => {
str.length
)}`
}
+
+export const stringToHex = (str: string) => {
+ // Convert the string to an array of UTF-16 code units using the spread operator
+ const codeUnits = [...str]
+
+ // Map each code unit to its hexadecimal representation
+ const hexChars = codeUnits.map((codeUnit) => {
+ // Convert the code unit to its hexadecimal representation with leading zeros
+ const hex = codeUnit.charCodeAt(0).toString(16).padStart(2, '0')
+ return hex
+ })
+
+ // Join the hexadecimal characters into a single string
+ const hexString = hexChars.join('')
+
+ // Return the resulting hexadecimal string
+ return hexString
+}
+
+export const hexToString = (hex: string) => {
+ // Split the hex string into pairs of two characters
+ const pairs = hex.match(/.{1,2}/g) || []
+
+ // Convert each pair from hexadecimal to its decimal equivalent,
+ // then convert each decimal value to its character representation
+ const chars = pairs.map((pair) => String.fromCharCode(parseInt(pair, 16)))
+
+ // Join the resulting characters into a single string
+ return chars.join('')
+}