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 ( - - Logo navigate('/')} /> + + + Logo navigate('/')} /> + + + {isAuthenticated && ( + setTabValue(value)} + > + + + + )} + + + + {!isAuthenticated && ( + + Logo 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 ( +