diff --git a/.gitea/workflows/release-production.yaml b/.gitea/workflows/release-production.yaml index 334eb11..d9529ad 100644 --- a/.gitea/workflows/release-production.yaml +++ b/.gitea/workflows/release-production.yaml @@ -29,4 +29,6 @@ jobs: - name: Release Build run: | npm -g install cloudron-surfer - surfer put --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io dist/* / + surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io + surfer put dist/* / --all -d + surfer put dist/.well-known / --all diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 0467f63..793c70c 100644 --- a/.gitea/workflows/release-staging.yaml +++ b/.gitea/workflows/release-staging.yaml @@ -29,4 +29,6 @@ jobs: - name: Release Build run: | npm -g install cloudron-surfer - surfer put --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server staging.sigit.io dist/* / + surfer config --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server staging.sigit.io + surfer put dist/* / --all -d + surfer put dist/.well-known / --all diff --git a/package-lock.json b/package-lock.json index e4a521d..11f2740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "jszip": "3.10.1", "lodash": "4.17.21", "mui-file-input": "4.0.4", - "nostr-tools": "2.3.1", + "nostr-tools": "2.7.0", "react": "^18.2.0", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", @@ -2507,12 +2507,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -3380,9 +3380,9 @@ "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", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -4137,9 +4137,9 @@ } }, "node_modules/nostr-tools": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.3.1.tgz", - "integrity": "sha512-qjKx2C3EzwiQOe2LPSPyCnp07pGz1pWaWjDXcm+L2y2c8iTECbvlzujDANm3nJUjWL5+LVRUVDovTZ1a/DC4Bg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.7.0.tgz", + "integrity": "sha512-jJoL2J1CBiKDxaXZww27nY/Wsuxzx7AULxmGKFce4sskDu1tohNyfnzYQ8BvDyvkstU8kNZUAXPL32tre33uig==", "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", diff --git a/package.json b/package.json index dc00ccf..7b1e1c2 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "jszip": "3.10.1", "lodash": "4.17.21", "mui-file-input": "4.0.4", - "nostr-tools": "2.3.1", + "nostr-tools": "2.7.0", "react": "^18.2.0", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", diff --git a/public/.well-known/nostr.json b/public/.well-known/nostr.json new file mode 100644 index 0000000..6dd4dd9 --- /dev/null +++ b/public/.well-known/nostr.json @@ -0,0 +1,15 @@ +{ + "names": { + "_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90" + }, + "relays": { + "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [ + "wss://brb.io", + "wss://nostr.v0l.io", + "wss://nostr.coinos.io", + "wss://rsslay.nostr.net", + "wss://relay.current.fyi", + "wss://nos.io" + ] + } +} \ No newline at end of file diff --git a/src/components/username.tsx b/src/components/username.tsx index 2945126..768d1a9 100644 --- a/src/components/username.tsx +++ b/src/components/username.tsx @@ -1,9 +1,9 @@ -import { Typography, IconButton, Box, useTheme } from '@mui/material' +import { Box, IconButton, Typography, useTheme } from '@mui/material' import { useSelector } from 'react-redux' +import { useNavigate } from 'react-router-dom' +import { getProfileRoute } from '../routes' import { State } from '../store/rootReducer' import { hexToNpub } from '../utils' -import { Link } from 'react-router-dom' -import { getProfileRoute } from '../routes' type Props = { username: string @@ -60,10 +60,17 @@ type UserProps = { */ export const UserComponent = ({ pubkey, name, image }: UserProps) => { const theme = useTheme() + const navigate = useNavigate() const npub = hexToNpub(pubkey) const roboImage = `https://robohash.org/${npub}.png?set=set3` + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + // navigate to user's profile + navigate(getProfileRoute(pubkey)) + } + return ( { borderStyle: 'solid', borderColor: `#${pubkey.substring(0, 6)}` }} + onClick={handleClick} /> - - - {name} - - + + {name} + ) } diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index 301f21d..88f8a75 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -61,11 +61,15 @@ export class MetadataController extends EventEmitter { verifyEvent(metadataEvent) // Verify the event's authenticity ) { // If there's no current event or the new metadata event is more recent - if (!currentEvent || metadataEvent.created_at > currentEvent.created_at) { + if ( + !currentEvent || + metadataEvent.created_at >= currentEvent.created_at + ) { // Handle the new metadata event this.handleNewMetadataEvent(metadataEvent) - return metadataEvent } + + return metadataEvent } // If no valid metadata event is found from the special relay, get the most popular relays @@ -123,11 +127,11 @@ export class MetadataController extends EventEmitter { // If cached metadata is found, check its validity if (cachedMetadataEvent) { - const oneDayInMS = 24 * 60 * 60 * 1000 // Number of milliseconds in one day + const oneWeekInMS = 7 * 24 * 60 * 60 * 1000 // Number of milliseconds in one week - // Check if the cached metadata is older than one day - if (Date.now() - cachedMetadataEvent.cachedAt > oneDayInMS) { - // If older than one day, find the metadata from relays in background + // Check if the cached metadata is older than one week + if (Date.now() - cachedMetadataEvent.cachedAt > oneWeekInMS) { + // If older than one week, find the metadata from relays in background this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event) } @@ -141,6 +145,22 @@ export class MetadataController extends EventEmitter { } public findRelayListMetadata = async (hexKey: string) => { + let relayEvent: Event | null = null + + // Attempt to retrieve the metadata event from the local cache + const cachedRelayListMetadataEvent = + await localCache.getUserRelayListMetadata(hexKey) + + if (cachedRelayListMetadataEvent) { + const oneWeekInMS = 7 * 24 * 60 * 60 * 1000 // Number of milliseconds in one week + + // Check if the cached event is not older than one week + if (Date.now() - cachedRelayListMetadataEvent.cachedAt < oneWeekInMS) { + relayEvent = cachedRelayListMetadataEvent.event + } + } + + // define filter for relay list const eventFilter: Filter = { kinds: [kinds.RelayList], authors: [hexKey] @@ -148,19 +168,38 @@ export class MetadataController extends EventEmitter { const pool = new SimplePool() - let relayEvent = await pool - .get([this.specialMetadataRelay], eventFilter) - .catch((err) => { - console.error(err) - return null - }) + // Try to get the relayList event from a special relay (wss://purplepag.es) + if (!relayEvent) { + relayEvent = await pool + .get([this.specialMetadataRelay], eventFilter) + .then((event) => { + if (event) { + // update the event in local cache + localCache.addUserRelayListMetadata(event) + } + return event + }) + .catch((err) => { + console.error(err) + return null + }) + } if (!relayEvent) { + // If no valid relayList event is found from the special relay, get the most popular relays const mostPopularRelays = await this.nostrController.getMostPopularRelays() + // Query the most popular relays for relayList event relayEvent = await pool .get(mostPopularRelays, eventFilter) + .then((event) => { + if (event) { + // update the event in local cache + localCache.addUserRelayListMetadata(event) + } + return event + }) .catch((err) => { console.error(err) return null diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 049f99a..13787e5 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -2,46 +2,48 @@ import NDK, { NDKEvent, NDKNip46Signer, NDKPrivateKeySigner, + NDKSubscription, NDKUser, - NostrEvent, - NDKSubscription + NostrEvent } from '@nostr-dev-kit/ndk' +import axios from 'axios' import { Event, EventTemplate, - SimplePool, - UnsignedEvent, Filter, Relay, + SimplePool, + UnsignedEvent, finalizeEvent, + kinds, nip04, nip19, - kinds + nip44 } from 'nostr-tools' +import { toast } from 'react-toastify' import { EventEmitter } from 'tseep' import { - updateNsecbunkerPubkey, setMostPopularRelaysAction, + setRelayConnectionStatusAction, setRelayInfoAction, - setRelayConnectionStatusAction + updateNsecbunkerPubkey } from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' import { - SignedEvent, - RelayMap, - RelayStats, - RelayReadStats, - RelayInfoObject, + RelayConnectionState, RelayConnectionStatus, - RelayConnectionState + RelayInfoObject, + RelayMap, + RelayReadStats, + RelayStats, + SignedEvent } from '../types' import { compareObjects, getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' -import axios from 'axios' export class NostrController extends EventEmitter { private static instance: NostrController @@ -56,7 +58,8 @@ export class NostrController extends EventEmitter { } private getNostrObject = () => { - if (window.nostr) return window.nostr + // fix: this is not picking up type declaration from src/system/index.d.ts + if (window.nostr) return window.nostr as any throw new Error( `window.nostr object not present. Make sure you have an nostr extension installed/working properly.` @@ -220,29 +223,31 @@ export class NostrController extends EventEmitter { /** * Function will publish provided event to the provided relays + * + * @param event - The event to publish. + * @param relays - An array of relay URLs to publish the event to. + * @returns A promise that resolves to an array of relays where the event was successfully published. */ publishEvent = async (event: Event, relays: string[]) => { const simplePool = new SimplePool() + // Publish the event to all relays const promises = simplePool.publish(relays, event) - const results = await Promise.allSettled(promises) + // Use Promise.race to wait for the first successful publish + const firstSuccessfulPublish = await Promise.race( + promises.map((promise, index) => + promise.then(() => relays[index]).catch(() => null) + ) + ) - const publishedRelays: string[] = [] - - console.log('results of publish event :>> ', results) - - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - publishedRelays.push(relays[index]) - } - }) - - if (publishedRelays.length === 0) { + if (!firstSuccessfulPublish) { + // If no publish was successful, collect the reasons for failures const failedPublishes: any[] = [] const fallbackRejectionReason = 'Attempt to publish an event has been rejected with unknown reason.' + const results = await Promise.allSettled(promises) results.forEach((res, index) => { if (res.status === 'rejected') { failedPublishes.push({ @@ -257,7 +262,187 @@ export class NostrController extends EventEmitter { throw failedPublishes } - return publishedRelays + // Continue publishing to other relays in the background + promises.forEach((promise, index) => { + promise.catch((err) => { + console.log(`Failed to publish to ${relays[index]}`, err) + }) + }) + + return [firstSuccessfulPublish] + } + + /** + * Asynchronously retrieves an event from a set of relays based on a provided filter. + * If no relays are specified, it defaults to using connected relays. + * + * @param {Filter} filter - The filter criteria to find the event. + * @param {string[]} [relays] - An optional array of relay URLs to search for the event. + * @returns {Promise} - Returns a promise that resolves to the found event or null if not found. + */ + getEvent = async ( + filter: Filter, + relays?: string[] + ): Promise => { + // If no relays are provided or the provided array is empty, use connected relays if available. + if (!relays || relays.length === 0) { + relays = this.connectedRelays + ? this.connectedRelays.map((relay) => relay.url) + : [] + } + + // If still no relays are available, reject the promise with an error message. + if (relays.length === 0) { + return Promise.reject('Provide some relays to find the event') + } + + // Create a new instance of SimplePool to handle the relay connections and event retrieval. + const pool = new SimplePool() + + // Attempt to retrieve the event from the specified relays using the filter criteria. + const event = await pool.get(relays, filter).catch((err) => { + // Log any errors that occur during the event retrieval process. + console.log('An error occurred in finding the event', err) + // Show an error toast notification to the user. + toast.error('An error occurred in finding the event') + // Return null if an error occurs, indicating that no event was found. + return null + }) + + // Return the found event, or null if an error occurred. + return event + } + + /** + * Encrypts the given content for the specified receiver using NIP-44 encryption. + * + * @param receiver The public key of the receiver. + * @param content The content to be encrypted. + * @returns The encrypted content as a string. + * @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported. + */ + nip44Encrypt = async (receiver: string, content: string) => { + // Retrieve the current login method from the application's redux state. + const loginMethod = (store.getState().auth as AuthState).loginMethod + + // Handle encryption when the login method is via an extension. + if (loginMethod === LoginMethods.extension) { + const nostr = this.getNostrObject() + + // Check if the nostr object supports NIP-44 encryption. + if (!nostr.nip44) { + throw new Error( + `Your nostr extension does not support nip44 encryption & decryption` + ) + } + + // Encrypt the content using NIP-44 provided by the nostr extension. + const encrypted = await nostr.nip44.encrypt(receiver, content) + return encrypted as string + } + + // Handle encryption when the login method is via a private key. + if (loginMethod === LoginMethods.privateKey) { + const keys = (store.getState().auth as AuthState).keyPair + + // Check if the private and public key pair is available. + if (!keys) { + throw new Error( + `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` + ) + } + + // Decode the private key. + const { private: nsec } = keys + const privateKey = nip19.decode(nsec).data as Uint8Array + + // Generate the conversation key using NIP-44 utilities. + const nip44ConversationKey = nip44.v2.utils.getConversationKey( + privateKey, + receiver + ) + + // Encrypt the content using the generated conversation key. + const encrypted = nip44.v2.encrypt(content, nip44ConversationKey) + + return encrypted + } + + // Throw an error if the login method is nsecBunker (not supported). + if (loginMethod === LoginMethods.nsecBunker) { + throw new Error( + `nip44 encryption is not yet supported for login method '${LoginMethods.nsecBunker}'` + ) + } + + // Throw an error if the login method is undefined or unsupported. + throw new Error('Login method is undefined') + } + + /** + * Decrypts the given content from the specified sender using NIP-44 decryption. + * + * @param sender The public key of the sender. + * @param content The encrypted content to be decrypted. + * @returns The decrypted content as a string. + * @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported. + */ + nip44Decrypt = async (sender: string, content: string) => { + // Retrieve the current login method from the application's redux state. + const loginMethod = (store.getState().auth as AuthState).loginMethod + + // Handle decryption when the login method is via an extension. + if (loginMethod === LoginMethods.extension) { + const nostr = this.getNostrObject() + + // Check if the nostr object supports NIP-44 decryption. + if (!nostr.nip44) { + throw new Error( + `Your nostr extension does not support nip44 encryption & decryption` + ) + } + + // Decrypt the content using NIP-44 provided by the nostr extension. + const decrypted = await nostr.nip44.decrypt(sender, content) + return decrypted as string + } + + // Handle decryption when the login method is via a private key. + if (loginMethod === LoginMethods.privateKey) { + const keys = (store.getState().auth as AuthState).keyPair + + // Check if the private and public key pair is available. + if (!keys) { + throw new Error( + `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` + ) + } + + // Decode the private key. + const { private: nsec } = keys + const privateKey = nip19.decode(nsec).data as Uint8Array + + // Generate the conversation key using NIP-44 utilities. + const nip44ConversationKey = nip44.v2.utils.getConversationKey( + privateKey, + sender + ) + + // Decrypt the content using the generated conversation key. + const decrypted = nip44.v2.decrypt(content, nip44ConversationKey) + + return decrypted + } + + // Throw an error if the login method is nsecBunker (not supported). + if (loginMethod === LoginMethods.nsecBunker) { + throw new Error( + `nip44 decryption is not yet supported for login method '${LoginMethods.nsecBunker}'` + ) + } + + // Throw an error if the login method is undefined or unsupported. + throw new Error('Login method is undefined') } /** @@ -330,7 +515,7 @@ export class NostrController extends EventEmitter { } } - nip04Encrypt = async (receiver: string, content: string) => { + nip04Encrypt = async (receiver: string, content: string): Promise => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (loginMethod === LoginMethods.extension) { @@ -378,6 +563,61 @@ export class NostrController extends EventEmitter { throw new Error('Login method is undefined') } + /** + * Decrypts a given content based on the current login method. + * + * @param sender - The sender's public key. + * @param content - The encrypted content to decrypt. + * @returns A promise that resolves to the decrypted content. + */ + nip04Decrypt = async (sender: string, content: string): Promise => { + 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 decrypted = await nostr.nip04.decrypt(sender, content) + return decrypted + } + + if (loginMethod === LoginMethods.privateKey) { + const keys = (store.getState().auth as AuthState).keyPair + + if (!keys) { + throw new Error( + `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` + ) + } + + const { private: nsec } = keys + const privateKey = nip19.decode(nsec).data as Uint8Array + + const decrypted = await nip04.decrypt(privateKey, sender, content) + return decrypted + } + + if (loginMethod === LoginMethods.nsecBunker) { + const user = new NDKUser({ pubkey: sender }) + + this.remoteSigner?.on('authUrl', (authUrl) => { + this.emit('nsecbunker-auth', authUrl) + }) + + if (!this.remoteSigner) throw new Error('Remote signer is undefined.') + const decrypted = await this.remoteSigner.decrypt(user, content) + + return decrypted + } + + 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 diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index c63e74d..60b052b 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,29 +1,40 @@ import { Box } from '@mui/material' import Container from '@mui/material/Container' +import { Event, kinds } from 'nostr-tools' import { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Outlet } from 'react-router-dom' import { AppBar } from '../components/AppBar/AppBar' -import { restoreState, setAuthState, setMetadataEvent } from '../store/actions' +import { LoadingSpinner } from '../components/LoadingSpinner' +import { MetadataController, NostrController } from '../controllers' +import { + restoreState, + setAuthState, + setMetadataEvent, + updateUserAppData +} from '../store/actions' +import { LoginMethods } from '../store/auth/types' +import { State } from '../store/rootReducer' +import { Dispatch } from '../store/store' +import { setUserRobotImage } from '../store/userRobotImage/action' import { clearAuthToken, clearState, getRoboHashPicture, + getUsersAppData, loadState, - saveNsecBunkerDelegatedKey + saveNsecBunkerDelegatedKey, + subscribeForSigits } from '../utils' -import { LoadingSpinner } from '../components/LoadingSpinner' -import { Dispatch } from '../store/store' -import { MetadataController, NostrController } from '../controllers' -import { LoginMethods } from '../store/auth/types' -import { setUserRobotImage } from '../store/userRobotImage/action' -import { State } from '../store/rootReducer' -import { Event, kinds } from 'nostr-tools' +import { useAppSelector } from '../hooks' +import { SubCloser } from 'nostr-tools/abstract-pool' export const MainLayout = () => { const dispatch: Dispatch = useDispatch() const [isLoading, setIsLoading] = useState(true) + const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`) const authState = useSelector((state: State) => state.auth) + const usersAppData = useAppSelector((state) => state.userAppData) useEffect(() => { const metadataController = new MetadataController() @@ -83,11 +94,33 @@ export const MainLayout = () => { metadataController.findMetadata(usersPubkey).then((metadataEvent) => { if (metadataEvent) handleMetadataEvent(metadataEvent) }) + } else { + setIsLoading(false) + } + } else { + setIsLoading(false) + } + }, [dispatch]) + + useEffect(() => { + let subCloser: SubCloser | null = null + + if (authState.loggedIn && usersAppData) { + const pubkey = authState.usersPubkey || authState.keyPair?.public + + if (pubkey) { + subscribeForSigits(pubkey).then((res) => { + subCloser = res || null + }) } } - setIsLoading(false) - }, [dispatch]) + return () => { + if (subCloser) { + subCloser.close() + } + } + }, [authState, usersAppData]) /** * When authState change user logged in / or app reloaded @@ -101,10 +134,20 @@ export const MainLayout = () => { if (pubkey) { dispatch(setUserRobotImage(getRoboHashPicture(pubkey))) } + + setIsLoading(true) + setLoadingSpinnerDesc(`Fetching user's app data`) + getUsersAppData() + .then((appData) => { + if (appData) { + dispatch(updateUserAppData(appData)) + } + }) + .finally(() => setIsLoading(false)) } }, [authState]) - if (isLoading) return + if (isLoading) return return ( <> diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 3789411..f90ee77 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -18,50 +18,60 @@ import { Tooltip, Typography } from '@mui/material' +import type { Identifier, XYCoord } from 'dnd-core' +import saveAs from 'file-saver' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' +import { Event, kinds } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' +import { DndProvider, useDrag, useDrop } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' import { useSelector } from 'react-redux' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserComponent } from '../../components/username' import { MetadataController, NostrController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { State } from '../../store/rootReducer' -import { Meta, ProfileMetadata, User, UserRole } from '../../types' +import { + CreateSignatureEventContent, + Meta, + ProfileMetadata, + User, + UserRole +} from '../../types' import { encryptArrayBuffer, + formatTimestamp, generateEncryptionKey, + generateKeys, + generateKeysFile, getHash, hexToNpub, isOnline, + now, npubToHex, queryNip05, - sendDM, + sendNotification, shorten, signEventForMetaFile, + updateUsersAppData, uploadToFileStorage } from '../../utils' import styles from './style.module.scss' -import { DndProvider } from 'react-dnd' -import { HTML5Backend } from 'react-dnd-html5-backend' -import type { Identifier, XYCoord } from 'dnd-core' -import { useDrag, useDrop } from 'react-dnd' -import saveAs from 'file-saver' -import CopyModal from '../../components/copyModal' -import { Event, kinds } from 'nostr-tools' export const CreatePage = () => { const navigate = useNavigate() + const location = useLocation() + const { uploadedFile } = location.state || {} + const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const [openCopyModal, setOpenCopyModel] = useState(false) - const [textToCopy, setTextToCopy] = useState('') const [authUrl, setAuthUrl] = useState() - const [title, setTitle] = useState('') + const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [selectedFiles, setSelectedFiles] = useState([]) const [userInput, setUserInput] = useState('') @@ -74,6 +84,17 @@ export const CreatePage = () => { const nostrController = NostrController.getInstance() + // Set up event listener for authentication event + nostrController.on('nsecbunker-auth', (url) => { + setAuthUrl(url) + }) + + useEffect(() => { + if (uploadedFile) { + setSelectedFiles([uploadedFile]) + } + }, [uploadedFile]) + useEffect(() => { if (usersPubkey) { setUsers((prev) => { @@ -221,194 +242,376 @@ export const CreatePage = () => { ) } - const handleCreate = async () => { + // Validate inputs before proceeding + const validateInputs = (): boolean => { if (!title.trim()) { toast.error('Title can not be empty') - return + return false } if (users.length === 0) { toast.error( - 'No signer/viewer is provided. At least add one signer or viewer.' + 'No signer/viewer is provided. At least add one signer or viewer.' ) - return + return false } if (selectedFiles.length === 0) { toast.error('No file is selected. Select at least 1 file') - return + return false } - setIsLoading(true) - setLoadingSpinnerDesc('Generating hashes for files') + return true + } + // Handle errors during file arrayBuffer conversion + const handleFileError = (file: File) => (err: any) => { + console.log( + `Error while getting arrayBuffer of file ${file.name} :>> `, + err + ) + toast.error( + err.message || `Error while getting arrayBuffer of file ${file.name}` + ) + return null + } + + // Generate hash for each selected file + const generateFileHashes = async (): Promise<{ + [key: string]: string + } | null> => { const fileHashes: { [key: string]: string } = {} - // generating file hashes for (const file of selectedFiles) { - const arraybuffer = await file.arrayBuffer().catch((err) => { - console.log( - `err while getting arrayBuffer of file ${file.name} :>> `, - err - ) - toast.error( - err.message || `err while getting arrayBuffer of file ${file.name}` - ) - return null - }) - - if (!arraybuffer) return + const arraybuffer = await file.arrayBuffer().catch(handleFileError(file)) + if (!arraybuffer) return null const hash = await getHash(arraybuffer) - if (!hash) { - setIsLoading(false) - return + return null } fileHashes[file.name] = hash } - const zip = new JSZip() + return fileHashes + } - // zipping files - selectedFiles.forEach((file) => { - zip.file(`files/${file.name}`, file) - }) - - const signers = users.filter((user) => user.role === UserRole.signer) - const viewers = users.filter((user) => user.role === UserRole.viewer) - - setLoadingSpinnerDesc('Signing nostr event') - const createSignature = await signEventForMetaFile( - JSON.stringify({ - signers: signers.map((signer) => hexToNpub(signer.pubkey)), - viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), - fileHashes - }), - nostrController, - setIsLoading - ) - - if (!createSignature) return - - // create content for meta file - const meta: Meta = { - title, - createSignature: JSON.stringify(createSignature, null, 2), - docSignatures: {} - } - - try { - const stringifiedMeta = JSON.stringify(meta, null, 2) - zip.file('meta.json', stringifiedMeta) - - const metaHash = await getHash(stringifiedMeta) - if (!metaHash) return - - const metaHashJson = { - [usersPubkey!]: metaHash - } - - zip.file('hashes.json', JSON.stringify(metaHashJson, null, 2)) - } catch (err) { - console.error(err) - toast.error('An error occurred in converting meta json to string') - return - } + // Handle errors during zip file generation + const handleZipError = (err: any) => { + console.log('Error in zip:>> ', err) + setIsLoading(false) + toast.error(err.message || 'Error occurred in generating zip file') + return null + } + // Generate the zip file + const generateZipFile = async (zip: JSZip): Promise => { setLoadingSpinnerDesc('Generating zip file') const arraybuffer = await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', - compressionOptions: { - level: 6 - } + 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 + .catch(handleZipError) + + return arraybuffer + } + + // Encrypt the zip file with the generated encryption key + const encryptZipFile = async ( + arraybuffer: ArrayBuffer, + encryptionKey: string + ): Promise => { + setLoadingSpinnerDesc('Encrypting zip file') + return encryptArrayBuffer(arraybuffer, encryptionKey) + } + + // create final zip file for offline mode + const createFinalZipFile = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ): Promise => { + // Get the current timestamp in seconds + const unixNow = now() + const blob = new Blob([encryptedArrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed.sigit`, { + type: 'application/sigit' + }) + + const firstSigner = users.filter((user) => user.role === UserRole.signer)[0] + + const keysFileContent = await generateKeysFile( + [firstSigner.pubkey], + encryptionKey + ) + if (!keysFileContent) return null + + const zip = new JSZip() + zip.file(`compressed.sigit`, file) + zip.file('keys.json', keysFileContent) + + const arraybuffer = await zip + .generateAsync({ + type: 'arraybuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 } }) + .catch(handleZipError) - if (!arraybuffer) return + if (!arraybuffer) return null + const finalZipFile = new File( + [new Blob([arraybuffer])], + `${unixNow}.sigit.zip`, + { + type: 'application/zip' + } + ) + + return finalZipFile + } + + // Handle errors during file upload + const handleUploadError = (err: any) => { + console.log('Error in upload:>> ', err) + setIsLoading(false) + toast.error(err.message || 'Error occurred in uploading file') + return null + } + + // Upload the file to the storage + const uploadFile = async ( + arrayBuffer: ArrayBuffer + ): Promise => { + const unixNow = now() + const blob = new Blob([arrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed-${unixNow}.sigit`, { + type: 'application/sigit' + }) + + const fileUrl = await uploadToFileStorage(file) + .then((url) => { + toast.success('files.zip uploaded to file storage') + return url + }) + .catch(handleUploadError) + + return fileUrl + } + + // Manage offline scenarios for signing or viewing the file + const handleOfflineFlow = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ) => { + const finalZipFile = await createFinalZipFile( + encryptedArrayBuffer, + encryptionKey + ) + + if (!finalZipFile) return + + saveAs(finalZipFile, 'request.sigit.zip') + } + + const generateFilesZip = async (): Promise => { + const zip = new JSZip() + selectedFiles.forEach((file) => { + zip.file(file.name, file) + }) + + const arraybuffer = await zip + .generateAsync({ + type: 'arraybuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }) + .catch(handleZipError) + + return arraybuffer + } + + const generateCreateSignature = async ( + fileHashes: { + [key: string]: string + }, + zipUrl: string + ) => { + const signers = users.filter((user) => user.role === UserRole.signer) + const viewers = users.filter((user) => user.role === UserRole.viewer) + + const content: CreateSignatureEventContent = { + signers: signers.map((signer) => hexToNpub(signer.pubkey)), + viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), + fileHashes, + zipUrl, + title + } + + setLoadingSpinnerDesc('Signing nostr event for create signature') + + const createSignature = await signEventForMetaFile( + JSON.stringify(content), + nostrController, + setIsLoading + ).catch(() => { + console.log('An error occurred in signing event for meta file', error) + toast.error('An error occurred in signing event for meta file') + return null + }) + + if (!createSignature) return null + + return JSON.stringify(createSignature, null, 2) + } + + // Send notifications to signers and viewers + const sendNotifications = (meta: Meta) => { + const signers = users.filter((user) => user.role === UserRole.signer) + const viewers = users.filter((user) => user.role === UserRole.viewer) + + // no need to send notification to self so remove it from the list + const receivers = ( + signers.length > 0 + ? [signers[0].pubkey] + : viewers.map((viewer) => viewer.pubkey) + ).filter((receiver) => receiver !== usersPubkey) + + const promises = receivers.map((receiver) => + sendNotification(receiver, meta) + ) + + return promises + } + + const handleCreate = async () => { + if (!validateInputs()) return + + setIsLoading(true) + setLoadingSpinnerDesc('Generating file hashes') + const fileHashes = await generateFileHashes() + if (!fileHashes) { + setIsLoading(false) + return + } + + setLoadingSpinnerDesc('Generating encryption key') const encryptionKey = await generateEncryptionKey() - setLoadingSpinnerDesc('Encrypting zip file') - const encryptedArrayBuffer = await encryptArrayBuffer( - arraybuffer, - encryptionKey - ).finally(() => setIsLoading(false)) - - const blob = new Blob([encryptedArrayBuffer]) - if (await isOnline()) { - setIsLoading(true) - setLoadingSpinnerDesc('Uploading zip file to file storage.') - const fileUrl = await uploadToFileStorage(blob, nostrController) - .then((url) => { - toast.success('zip file uploaded to file storage') - return url - }) - .catch((err) => { - console.log('err in upload:>> ', err) - setIsLoading(false) - toast.error(err.message || 'Error occurred in uploading zip file') - return null - }) - - if (!fileUrl) return - - setLoadingSpinnerDesc('Sending DM to signers/viewers') - - // send DM to first signer if exists - if (signers.length > 0) { - await sendDM( - fileUrl, - encryptionKey, - signers[0].pubkey, - nostrController, - true, - setAuthUrl - ) - } else { - // send DM to all viewers if no signer - for (const viewer of viewers) { - // todo: execute in parallel - await sendDM( - fileUrl, - encryptionKey, - viewer.pubkey, - nostrController, - false, - setAuthUrl - ) - } + setLoadingSpinnerDesc('generating files.zip') + const arrayBuffer = await generateFilesZip() + if (!arrayBuffer) { + setIsLoading(false) + return } - setIsLoading(false) - navigate( - `${appPrivateRoutes.sign}?file=${encodeURIComponent( - fileUrl - )}&key=${encodeURIComponent(encryptionKey)}` + setLoadingSpinnerDesc('Encrypting files.zip') + const encryptedArrayBuffer = await encryptZipFile( + arrayBuffer, + encryptionKey ) - } else { - if (signers[0] && signers[0].pubkey === usersPubkey) { - // Create a File object with the Blob data - const file = new File([blob], `compressed.sigit`, { - type: 'application/sigit' + + setLoadingSpinnerDesc('Uploading files.zip to file storage') + const fileUrl = await uploadFile(encryptedArrayBuffer) + if (!fileUrl) { + setIsLoading(false) + return + } + + setLoadingSpinnerDesc('Generating create signature') + const createSignature = await generateCreateSignature(fileHashes, fileUrl) + if (!createSignature) { + setIsLoading(false) + return + } + + setLoadingSpinnerDesc('Generating keys for decryption') + + // generate key pairs for decryption + const pubkeys = users.map((user) => user.pubkey) + // also add creator in the list + if (pubkeys.includes(usersPubkey!)) { + pubkeys.push(usersPubkey!) + } + + const keys = await generateKeys(pubkeys, encryptionKey) + + if (!keys) { + setIsLoading(false) + return + } + const meta: Meta = { + createSignature, + keys, + modifiedAt: now(), + docSignatures: {} + } + + setLoadingSpinnerDesc('Updating user app data') + const event = await updateUsersAppData(meta) + if (!event) { + setIsLoading(false) + return + } + + setLoadingSpinnerDesc('Sending notifications to counterparties') + const promises = sendNotifications(meta) + + await Promise.all(promises) + .then(() => { + toast.success('Notifications sent successfully') + }) + .catch(() => { + toast.error('Failed to publish notifications') }) - navigate(appPrivateRoutes.sign, { state: { file, encryptionKey } }) - } else { - saveAs(blob, 'request.sigit') - setTextToCopy(encryptionKey) - setOpenCopyModel(true) + navigate(appPrivateRoutes.sign, { state: { meta: meta } }) + } else { + const zip = new JSZip() + + selectedFiles.forEach((file) => { + zip.file(`files/${file.name}`, file) + }) + + setLoadingSpinnerDesc('Generating create signature') + const createSignature = await generateCreateSignature(fileHashes, '') + if (!createSignature) { + setIsLoading(false) + return } + + const meta: Meta = { + createSignature, + modifiedAt: now(), + docSignatures: {} + } + + // add meta to zip + try { + const stringifiedMeta = JSON.stringify(meta, null, 2) + zip.file('meta.json', stringifiedMeta) + } catch (err) { + console.error(err) + toast.error('An error occurred in converting meta json to string') + return null + } + + const arrayBuffer = await generateZipFile(zip) + if (!arrayBuffer) return + + setLoadingSpinnerDesc('Encrypting zip file') + const encryptedArrayBuffer = await encryptZipFile( + arrayBuffer, + encryptionKey + ) + + await handleOfflineFlow(encryptedArrayBuffer, encryptionKey) } } @@ -506,15 +709,6 @@ export const CreatePage = () => { - { - setOpenCopyModel(false) - navigate(appPrivateRoutes.sign) - }} - title="Decryption key for Sigit file" - textToCopy={textToCopy} - /> ) } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 0afb321..7f78987 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,81 +1,385 @@ -import { - Add, - CalendarMonth, - Description, - PersonOutline, - Upload -} from '@mui/icons-material' -import { Box, Button, Typography } from '@mui/material' +import { CalendarMonth, Description, Upload } from '@mui/icons-material' +import { Box, Button, Tooltip, Typography } from '@mui/material' +import JSZip from 'jszip' +import { Event, kinds, verifyEvent } from 'nostr-tools' +import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { appPrivateRoutes } from '../../routes' +import { toast } from 'react-toastify' +import { UserComponent } from '../../components/username' +import { MetadataController } from '../../controllers' +import { useAppSelector } from '../../hooks' +import { appPrivateRoutes, appPublicRoutes } from '../../routes' +import { CreateSignatureEventContent, Meta, ProfileMetadata } from '../../types' +import { + formatTimestamp, + hexToNpub, + npubToHex, + parseJson, + shorten +} from '../../utils' import styles from './style.module.scss' export const HomePage = () => { const navigate = useNavigate() + const fileInputRef = useRef(null) + const [sigits, setSigits] = useState([]) + const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( + {} + ) + const usersAppData = useAppSelector((state) => state.userAppData) + + useEffect(() => { + if (usersAppData) { + setSigits(Object.values(usersAppData.sigits)) + } + }, [usersAppData]) + + const handleUploadClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click() + } + } + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0] + if (file) { + // Check if the file extension is .sigit.zip + const fileName = file.name + const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters + if (fileExtension === '.sigit.zip') { + const zip = await JSZip.loadAsync(file).catch((err) => { + console.log('err in loading zip file :>> ', err) + toast.error(err.message || 'An error occurred in loading zip file.') + return null + }) + + if (!zip) return + + // navigate to sign page if zip contains keys.json + if ('keys.json' in zip.files) { + return navigate(appPrivateRoutes.sign, { + state: { uploadedZip: file } + }) + } + + // navigate to verify page if zip contains meta.json + if ('meta.json' in zip.files) { + return navigate(appPublicRoutes.verify, { + state: { uploadedZip: file } + }) + } + + toast.error('Invalid zip file') + return + } + + // navigate to create page + navigate(appPrivateRoutes.create, { state: { uploadedFile: file } }) + } + } return ( - - - - Sigits + <> + + + + Sigits + + {/* This is for desktop view */} + + + + + {/* This is for mobile view */} + + + + + + + + {sigits.map((sigit, index) => ( + + ))} + + + + ) +} + +type SigitProps = { + meta: Meta + profiles: { [key: string]: ProfileMetadata } + setProfiles: Dispatch> +} + +enum SignedStatus { + Partial = 'Partially Signed', + Complete = 'Completely Signed' +} + +const DisplaySigit = ({ meta, profiles, setProfiles }: SigitProps) => { + const navigate = useNavigate() + + const [title, setTitle] = useState() + const [createdAt, setCreatedAt] = useState('') + const [submittedBy, setSubmittedBy] = useState() + const [signers, setSigners] = useState<`npub1${string}`[]>([]) + const [signedStatus, setSignedStatus] = useState( + SignedStatus.Partial + ) + + useEffect(() => { + const extractInfo = async () => { + const createSignatureEvent = await parseJson( + meta.createSignature + ).catch((err) => { + console.log('err in parsing the createSignature event:>> ', err) + toast.error( + err.message || 'error occurred in parsing the create signature event' + ) + return null + }) + + if (!createSignatureEvent) return + + // created_at in nostr events are stored in seconds + // convert it to ms before formatting + setCreatedAt(formatTimestamp(createSignatureEvent.created_at * 1000)) + + const createSignatureContent = + await parseJson( + createSignatureEvent.content + ).catch((err) => { + console.log( + `err in parsing the createSignature event's content :>> `, + err + ) + return null + }) + + if (!createSignatureContent) return + + setTitle(createSignatureContent.title) + setSubmittedBy(createSignatureEvent.pubkey) + setSigners(createSignatureContent.signers) + + const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] + const isCompletelySigned = createSignatureContent.signers.every( + (signer) => signedBy.includes(signer) + ) + if (isCompletelySigned) { + setSignedStatus(SignedStatus.Complete) + } + } + extractInfo() + }, [meta]) + + useEffect(() => { + const hexKeys: string[] = [] + + if (submittedBy) { + hexKeys.push(npubToHex(submittedBy)!) + } + hexKeys.push(...signers.map((signer) => npubToHex(signer)!)) + + const metadataController = new MetadataController() + hexKeys.forEach((key) => { + if (!(key in profiles)) { + const handleMetadataEvent = (event: Event) => { + const metadataContent = + metadataController.extractProfileMetadataContent(event) + + if (metadataContent) + setProfiles((prev) => ({ + ...prev, + [key]: metadataContent + })) + } + + metadataController.on(key, (kind: number, event: Event) => { + if (kind === kinds.Metadata) { + handleMetadataEvent(event) + } + }) + + metadataController + .findMetadata(key) + .then((metadataEvent) => { + if (metadataEvent) handleMetadataEvent(metadataEvent) + }) + .catch((err) => { + console.error(`error occurred in finding metadata for: ${key}`, err) + }) + } + }) + }, [submittedBy, signers]) + + const handleNavigation = () => { + if (signedStatus === SignedStatus.Complete) { + navigate(appPublicRoutes.verify, { state: { meta } }) + } else { + navigate(appPrivateRoutes.sign, { state: { meta } }) + } + } + + return ( + + + + + {title} + + {submittedBy && + (function () { + const profile = profiles[submittedBy] + return ( + + ) + })()} + + + {createdAt} - - - - - - - + + {signers.map((signer) => { + const pubkey = npubToHex(signer)! + const profile = profiles[pubkey] + + return ( + + ) + })} + ) } -const PlaceHolder = () => { +enum SignStatus { + Signed = 'Signed', + Pending = 'Pending', + Invalid = 'Invalid Sign' +} + +type DisplaySignerProps = { + meta: Meta + profile: ProfileMetadata + pubkey: string +} + +const DisplaySigner = ({ meta, profile, pubkey }: DisplaySignerProps) => { + const [signStatus, setSignedStatus] = useState() + + useEffect(() => { + const updateSignStatus = async () => { + const npub = hexToNpub(pubkey) + if (npub in meta.docSignatures) { + parseJson(meta.docSignatures[npub]) + .then((event) => { + const isValidSignature = verifyEvent(event) + if (isValidSignature) { + setSignedStatus(SignStatus.Signed) + } else { + setSignedStatus(SignStatus.Invalid) + } + }) + .catch((err) => { + console.log(`err in parsing the docSignatures for ${npub}:>> `, err) + setSignedStatus(SignStatus.Invalid) + }) + } else { + setSignedStatus(SignStatus.Pending) + } + } + + updateSignStatus() + }, [meta, pubkey]) + return ( - - - - - - Title - - - - Sigit - - - - 07 Jun 10:23 AM - - - - - - Sent - - placeholder@sigit.io - - - - Awaiting - - placeholder@sigit.io - - - + + + {signStatus} + + ) } diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index 69c5019..62135f1 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -15,7 +15,6 @@ } .actionButtons { - display: flex; justify-content: center; align-items: center; gap: 10px; @@ -25,26 +24,44 @@ .submissions { display: flex; flex-direction: column; + gap: 10px; .item { display: flex; + gap: 10px; background-color: #efeae6; border-radius: 1rem; + cursor: pointer; .titleBox { display: flex; + flex: 4; flex-direction: column; - align-items: flex-start; + align-items: center; + overflow-wrap: anywhere; + gap: 10px; padding: 10px; - background-color: #e7e2df99; + background-color: #cdc8c499; border-top-left-radius: inherit; - border-bottom-left-radius: inherit; - .titleBoxItem { + .title { display: flex; justify-content: center; align-items: center; color: var(--mui-palette-primary-light); + font-size: 1.5rem; + + svg { + font-size: 1.5rem; + } + } + + .date { + display: flex; + justify-content: center; + align-items: center; + color: var(--mui-palette-primary-light); + font-size: 1rem; svg { font-size: 1rem; @@ -55,10 +72,10 @@ .signers { display: flex; flex-direction: column; - flex: 1; + flex: 6; justify-content: center; gap: 10px; - padding: 10px 0; + padding: 10px; color: var(--mui-palette-primary-light); .signerItem { diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 1833c77..5204ea7 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -1,66 +1,38 @@ -import { - Box, - Button, - IconButton, - List, - ListItem, - ListSubheader, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, - Tooltip, - Typography, - useTheme -} from '@mui/material' +import { Box, Button, Typography } from '@mui/material' import axios from 'axios' import saveAs from 'file-saver' import JSZip from 'jszip' import _ from 'lodash' import { MuiFileInput } from 'mui-file-input' -import { Event, kinds, verifyEvent } from 'nostr-tools' +import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' -import { useNavigate, useSearchParams, useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' -import { UserComponent } from '../../components/username' -import { MetadataController, NostrController } from '../../controllers' +import { NostrController } from '../../controllers' import { appPublicRoutes } from '../../routes' import { State } from '../../store/rootReducer' -import { - CreateSignatureEventContent, - Meta, - ProfileMetadata, - SignedEventContent, - User, - UserRole -} from '../../types' +import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { decryptArrayBuffer, encryptArrayBuffer, + extractZipUrlAndEncryptionKey, generateEncryptionKey, + generateKeysFile, getHash, hexToNpub, - parseJson, + isOnline, + now, npubToHex, + parseJson, readContentOfZipEntry, - sendDM, - shorten, + sendNotification, signEventForMetaFile, - uploadToFileStorage, - isOnline + updateUsersAppData } from '../../utils' +import { DisplayMeta } from './internal/displayMeta' import styles from './style.module.scss' -import { - Cancel, - CheckCircle, - Download, - HourglassTop -} from '@mui/icons-material' -import CopyModal from '../../components/copyModal' enum SignedStatus { Fully_Signed, User_Is_Next_Signer, @@ -70,21 +42,26 @@ enum SignedStatus { export const SignPage = () => { const navigate = useNavigate() const location = useLocation() - const { file, encryptionKey: encKey } = location.state || {} - const [searchParams, setSearchParams] = useSearchParams() + /** + * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json + * arrayBuffer will be received in navigation from create page in offline mode + * meta will be received in navigation from create & home page in online mode + */ + const { + meta: metaInNavState, + arrayBuffer: decryptedArrayBuffer, + uploadedZip + } = location.state || {} const [displayInput, setDisplayInput] = useState(false) const [selectedFile, setSelectedFile] = useState(null) - const [encryptionKey, setEncryptionKey] = useState('') - const [zip, setZip] = useState() + const [files, setFiles] = useState<{ [filename: string]: ArrayBuffer }>({}) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const [openCopyModal, setOpenCopyModel] = useState(false) - const [textToCopy, setTextToCopy] = useState('') const [meta, setMeta] = useState(null) const [signedStatus, setSignedStatus] = useState() @@ -112,41 +89,6 @@ export const SignPage = () => { const [authUrl, setAuthUrl] = useState() const nostrController = NostrController.getInstance() - useEffect(() => { - if (zip) { - const generateCurrentFileHashes = async () => { - const fileHashes: { [key: string]: string | null } = {} - const fileNames = Object.values(zip.files) - .filter((entry) => entry.name.startsWith('files/') && !entry.dir) - .map((entry) => entry.name) - - // generate hashes for all entries in files folder of zipArchive - // these hashes can be used to verify the originality of files - for (const fileName of fileNames) { - const arrayBuffer = await readContentOfZipEntry( - zip, - fileName, - 'arraybuffer' - ) - - if (arrayBuffer) { - const hash = await getHash(arrayBuffer) - - if (hash) { - fileHashes[fileName.replace(/^files\//, '')] = hash - } - } else { - fileHashes[fileName.replace(/^files\//, '')] = null - } - } - - setCurrentFileHashes(fileHashes) - } - - generateCurrentFileHashes() - } - }, [zip]) - useEffect(() => { if (signers.length > 0) { // check if all signers have signed then its fully signed @@ -192,36 +134,100 @@ export const SignPage = () => { }, [signers, signedBy, usersPubkey, submittedBy]) useEffect(() => { - const fileUrl = searchParams.get('file') - const key = searchParams.get('key') + const handleUpdatedMeta = async (meta: Meta) => { + const createSignatureEvent = await parseJson( + meta.createSignature + ).catch((err) => { + console.log('err in parsing the createSignature event:>> ', err) + toast.error( + err.message || 'error occurred in parsing the create signature event' + ) + setIsLoading(false) + return null + }) - if (fileUrl && key) { - setIsLoading(true) - setLoadingSpinnerDesc('Fetching file from file server') + if (!createSignatureEvent) return - axios - .get(fileUrl, { - responseType: 'arraybuffer' - }) - .then((res) => { - const fileName = fileUrl.split('/').pop() - const file = new File([res.data], fileName!) + const isValidCreateSignature = verifyEvent(createSignatureEvent) - decrypt(file, decodeURIComponent(key)).then((arrayBuffer) => { - if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) - }) - }) - .catch((err) => { - console.error(`error occurred in getting file from ${fileUrl}`, err) - toast.error( - err.message || `error occurred in getting file from ${fileUrl}` + if (!isValidCreateSignature) { + toast.error('Create signature is invalid') + setIsLoading(false) + return + } + + const createSignatureContent = + await parseJson( + createSignatureEvent.content + ).catch((err) => { + console.log( + `err in parsing the createSignature event's content :>> `, + err + ) + toast.error( + err.message || + `error occurred in parsing the create signature event's content` ) - }) - .finally(() => { setIsLoading(false) + return null }) - } else if (file && encKey) { - decrypt(file, decodeURIComponent(encKey)) + + if (!createSignatureContent) return + + setSigners(createSignatureContent.signers) + setViewers(createSignatureContent.viewers) + setCreatorFileHashes(createSignatureContent.fileHashes) + setSubmittedBy(createSignatureEvent.pubkey) + + setSignedBy(Object.keys(meta.docSignatures) as `npub1${string}`[]) + } + + if (meta) { + handleUpdatedMeta(meta) + } + }, [meta]) + + useEffect(() => { + if (metaInNavState) { + const processSigit = async () => { + setIsLoading(true) + setLoadingSpinnerDesc('Extracting zipUrl and encryption key from meta') + + const res = await extractZipUrlAndEncryptionKey(metaInNavState) + if (!res) { + setIsLoading(false) + return + } + + const { zipUrl, encryptionKey } = res + + setLoadingSpinnerDesc('Fetching file from file server') + axios + .get(zipUrl, { + responseType: 'arraybuffer' + }) + .then((res) => { + handleArrayBufferFromBlossom(res.data, encryptionKey) + setMeta(metaInNavState) + }) + .catch((err) => { + console.error(`error occurred in getting file from ${zipUrl}`, err) + toast.error( + err.message || `error occurred in getting file from ${zipUrl}` + ) + }) + .finally(() => { + setIsLoading(false) + }) + } + + processSigit() + } else if (decryptedArrayBuffer) { + handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() => + setIsLoading(false) + ) + } else if (uploadedZip) { + decrypt(uploadedZip) .then((arrayBuffer) => { if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) }) @@ -236,24 +242,155 @@ export const SignPage = () => { setIsLoading(false) setDisplayInput(true) } - }, [searchParams, file, encKey]) + }, [decryptedArrayBuffer, uploadedZip, metaInNavState]) - const decrypt = async (file: File, key: string) => { + const handleArrayBufferFromBlossom = async ( + arrayBuffer: ArrayBuffer, + encryptionKey: string + ) => { + // array buffer returned from blossom is encrypted. + // So, first decrypt it + const decrypted = await decryptArrayBuffer( + arrayBuffer, + encryptionKey + ).catch((err) => { + console.log('err in decryption:>> ', err) + toast.error(err.message || 'An error occurred in decrypting file.') + setIsLoading(false) + return null + }) + + if (!decrypted) return + + const zip = await JSZip.loadAsync(decrypted).catch((err) => { + console.log('err in loading zip file :>> ', err) + toast.error(err.message || 'An error occurred in loading zip file.') + setIsLoading(false) + return null + }) + + if (!zip) return + + const files: { [filename: string]: ArrayBuffer } = {} + const fileHashes: { [key: string]: string | null } = {} + const fileNames = Object.values(zip.files).map((entry) => entry.name) + + // generate hashes for all files in zipArchive + // these hashes can be used to verify the originality of files + for (const fileName of fileNames) { + const arrayBuffer = await readContentOfZipEntry( + zip, + fileName, + 'arraybuffer' + ) + + if (arrayBuffer) { + files[fileName] = arrayBuffer + + const hash = await getHash(arrayBuffer) + if (hash) { + fileHashes[fileName] = hash + } + } else { + fileHashes[fileName] = null + } + } + + setFiles(files) + setCurrentFileHashes(fileHashes) + } + + const parseKeysJson = async (zip: JSZip) => { + const keysFileContent = await readContentOfZipEntry( + zip, + 'keys.json', + 'string' + ) + + if (!keysFileContent) return null + + const parsedJSON = await parseJson<{ sender: string; keys: string[] }>( + keysFileContent + ).catch((err) => { + console.log(`Error parsing content of keys.json:`, err) + toast.error(err.message || `Error parsing content of keys.json`) + return null + }) + + return parsedJSON + } + + const decrypt = async (file: File) => { setLoadingSpinnerDesc('Decrypting file') - const encryptedArrayBuffer = await file.arrayBuffer() + const zip = await JSZip.loadAsync(file).catch((err) => { + console.log('err in loading zip file :>> ', err) + toast.error(err.message || 'An error occurred in loading zip file.') + return null + }) + if (!zip) return - const arrayBuffer = await decryptArrayBuffer(encryptedArrayBuffer, key) - .catch((err) => { - console.log('err in decryption:>> ', err) - toast.error(err.message || 'An error occurred in decrypting file.') - return null - }) - .finally(() => { - setIsLoading(false) + const parsedKeysJson = await parseKeysJson(zip) + if (!parsedKeysJson) return + + const encryptedArrayBuffer = await readContentOfZipEntry( + zip, + 'compressed.sigit', + 'arraybuffer' + ) + + if (!encryptedArrayBuffer) return + + const { keys, sender } = parsedKeysJson + + for (const key of keys) { + // Set up event listener for authentication event + nostrController.on('nsecbunker-auth', (url) => { + setAuthUrl(url) }) - return arrayBuffer + // Set up timeout promise to handle encryption timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Timeout occurred')) + }, 60000) // Timeout duration = 60 seconds + }) + + // decrypt the encryptionKey, with timeout + const encryptionKey = await Promise.race([ + nostrController.nip04Decrypt(sender, key), + timeoutPromise + ]) + .then((res) => { + return res + }) + .catch((err) => { + console.log('err :>> ', err) + return null + }) + .finally(() => { + setAuthUrl(undefined) // Clear authentication URL + }) + + // Return if encryption failed + if (!encryptionKey) continue + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer, + encryptionKey + ) + .catch((err) => { + console.log('err in decryption:>> ', err) + return null + }) + .finally(() => { + setIsLoading(false) + }) + + if (arrayBuffer) return arrayBuffer + } + + return null } const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => { @@ -269,7 +406,37 @@ export const SignPage = () => { if (!zip) return - setZip(zip) + const files: { [filename: string]: ArrayBuffer } = {} + const fileHashes: { [key: string]: string | null } = {} + const fileNames = Object.values(zip.files) + .filter((entry) => entry.name.startsWith('files/') && !entry.dir) + .map((entry) => entry.name) + + // generate hashes for all entries in files folder of zipArchive + // these hashes can be used to verify the originality of files + for (let fileName of fileNames) { + const arrayBuffer = await readContentOfZipEntry( + zip, + fileName, + 'arraybuffer' + ) + + fileName = fileName.replace(/^files\//, '') + if (arrayBuffer) { + files[fileName] = arrayBuffer + + const hash = await getHash(arrayBuffer) + if (hash) { + fileHashes[fileName] = hash + } + } else { + fileHashes[fileName] = null + } + } + + setFiles(files) + setCurrentFileHashes(fileHashes) + setDisplayInput(false) setLoadingSpinnerDesc('Parsing meta.json') @@ -296,64 +463,14 @@ export const SignPage = () => { } ) - if (!parsedMetaJson) return - - const createSignatureEvent = await parseJson( - parsedMetaJson.createSignature - ).catch((err) => { - console.log('err in parsing the createSignature event:>> ', err) - toast.error( - err.message || 'error occurred in parsing the create signature event' - ) - setIsLoading(false) - return null - }) - - if (!createSignatureEvent) return - - const isValidCreateSignature = verifyEvent(createSignatureEvent) - - if (!isValidCreateSignature) { - toast.error('Create signature is invalid') - setIsLoading(false) - return - } - - const createSignatureContent = await parseJson( - createSignatureEvent.content - ).catch((err) => { - console.log( - `err in parsing the createSignature event's content :>> `, - err - ) - toast.error( - err.message || - `error occurred in parsing the create signature event's content` - ) - setIsLoading(false) - return null - }) - - if (!createSignatureContent) return - - setSigners(createSignatureContent.signers) - setViewers(createSignatureContent.viewers) - setCreatorFileHashes(createSignatureContent.fileHashes) - setSubmittedBy(createSignatureEvent.pubkey) - - setSignedBy(Object.keys(parsedMetaJson.docSignatures) as `npub1${string}`[]) - setMeta(parsedMetaJson) } const handleDecrypt = async () => { - if (!selectedFile || !encryptionKey) return + if (!selectedFile) return setIsLoading(true) - const arrayBuffer = await decrypt( - selectedFile, - decodeURIComponent(encryptionKey) - ) + const arrayBuffer = await decrypt(selectedFile) if (!arrayBuffer) return @@ -361,171 +478,188 @@ export const SignPage = () => { } const handleSign = async () => { - if (!zip || !meta) return + if (Object.entries(files).length === 0 || !meta) return setIsLoading(true) - setLoadingSpinnerDesc('parsing hashes.json file') - - const hashesFileContent = await readContentOfZipEntry( - zip, - 'hashes.json', - 'string' - ) - - if (!hashesFileContent) { - setIsLoading(false) - return - } - - let hashes = await parseJson(hashesFileContent).catch((err) => { - console.log('err in parsing the content of hashes.json :>> ', err) - toast.error( - err.message || 'error occurred in parsing the content of hashes.json' - ) - setIsLoading(false) - return null - }) - - if (!hashes) return - - setLoadingSpinnerDesc('Generating hashes for files') setLoadingSpinnerDesc('Signing nostr event') const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!)) if (!prevSig) return - const signedEvent = await signEventForMetaFile( - JSON.stringify({ - prevSig - }), + const signedEvent = await signEventForMeta(prevSig) + if (!signedEvent) return + + const updatedMeta = updateMetaSignatures(meta, signedEvent) + + if (await isOnline()) { + await handleOnlineFlow(updatedMeta) + } else { + setMeta(updatedMeta) + setIsLoading(false) + } + } + + // Sign the event for the meta file + const signEventForMeta = async (prevSig: string) => { + return await signEventForMetaFile( + JSON.stringify({ prevSig }), nostrController, setIsLoading ) + } - if (!signedEvent) return - + // Update the meta signatures + const updateMetaSignatures = (meta: Meta, signedEvent: SignedEvent): Meta => { const metaCopy = _.cloneDeep(meta) - metaCopy.docSignatures = { ...metaCopy.docSignatures, [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) } + metaCopy.modifiedAt = now() + return metaCopy + } - const stringifiedMeta = JSON.stringify(metaCopy, null, 2) - zip.file('meta.json', stringifiedMeta) + // create final zip file + const createFinalZipFile = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ): Promise => { + // Get the current timestamp in seconds + const unixNow = Math.floor(Date.now() / 1000) + const blob = new Blob([encryptedArrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed.sigit`, { + type: 'application/sigit' + }) - const metaHash = await getHash(stringifiedMeta) - if (!metaHash) return + const isLastSigner = checkIsLastSigner(signers) - hashes = { - ...hashes, - [usersPubkey!]: metaHash + const userSet = new Set() + + if (isLastSigner) { + if (submittedBy) { + userSet.add(submittedBy) + } + + signers.forEach((signer) => { + userSet.add(npubToHex(signer)!) + }) + + viewers.forEach((viewer) => { + userSet.add(npubToHex(viewer)!) + }) + } else { + const usersNpub = hexToNpub(usersPubkey!) + const signerIndex = signers.indexOf(usersNpub) + const nextSigner = signers[signerIndex + 1] + userSet.add(npubToHex(nextSigner)!) } - zip.file('hashes.json', JSON.stringify(hashes, null, 2)) + const keysFileContent = await generateKeysFile( + Array.from(userSet), + encryptionKey + ) + if (!keysFileContent) return null - const arrayBuffer = await zip + const zip = new JSZip() + zip.file(`compressed.sigit`, file) + zip.file('keys.json', keysFileContent) + + 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 + compressionOptions: { level: 6 } }) + .catch(handleZipError) - if (!arrayBuffer) return + if (!arraybuffer) return null - const key = await generateEncryptionKey() - - setLoadingSpinnerDesc('Encrypting zip file') - const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) - - const blob = new Blob([encryptedArrayBuffer]) - - if (await isOnline()) { - setLoadingSpinnerDesc('Uploading zip file to file storage.') - const fileUrl = await uploadToFileStorage(blob, nostrController) - .then((url) => { - toast.success('zip file uploaded to file storage') - return url - }) - .catch((err) => { - console.log('err in upload:>> ', err) - setIsLoading(false) - toast.error(err.message || 'Error occurred in uploading zip file') - return null - }) - - if (!fileUrl) return - - // check if the current user is the last signer - const usersNpub = hexToNpub(usersPubkey!) - const lastSignerIndex = signers.length - 1 - const signerIndex = signers.indexOf(usersNpub) - const isLastSigner = signerIndex === lastSignerIndex - - // if current user is the last signer, then send DMs to all signers and viewers - if (isLastSigner) { - const userSet = new Set<`npub1${string}`>() - - if (submittedBy) { - userSet.add(hexToNpub(submittedBy)) - } - - signers.forEach((signer) => { - userSet.add(signer) - }) - - viewers.forEach((viewer) => { - userSet.add(viewer) - }) - - const users = Array.from(userSet) - - for (const user of users) { - // todo: execute in parallel - await sendDM( - fileUrl, - key, - npubToHex(user)!, - nostrController, - false, - setAuthUrl - ) - } - } else { - const nextSigner = signers[signerIndex + 1] - await sendDM( - fileUrl, - key, - npubToHex(nextSigner)!, - nostrController, - true, - setAuthUrl - ) + const finalZipFile = new File( + [new Blob([arraybuffer])], + `${unixNow}.sigit.zip`, + { + type: 'application/zip' } + ) + return finalZipFile + } + + // Handle errors during zip file generation + const handleZipError = (err: any) => { + console.log('Error in zip:>> ', err) + setIsLoading(false) + toast.error(err.message || 'Error occurred in generating zip file') + return null + } + + // Handle the online flow: update users app data and send notifications + const handleOnlineFlow = async (meta: Meta) => { + setLoadingSpinnerDesc('Updating users app data') + const updatedEvent = await updateUsersAppData(meta) + if (!updatedEvent) { setIsLoading(false) + return + } - // update search params with updated file url and encryption key - setSearchParams({ - file: fileUrl, - key: key + const userSet = new Set<`npub1${string}`>() + if (submittedBy && submittedBy !== usersPubkey) { + userSet.add(hexToNpub(submittedBy)) + } + + const usersNpub = hexToNpub(usersPubkey!) + const isLastSigner = checkIsLastSigner(signers) + if (isLastSigner) { + signers.forEach((signer) => { + if (signer !== usersNpub) { + userSet.add(signer) + } + }) + + viewers.forEach((viewer) => { + userSet.add(viewer) }) } else { - handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false)) + const currentSignerIndex = signers.indexOf(usersNpub) + const prevSigners = signers.slice(0, currentSignerIndex) + + prevSigners.forEach((signer) => { + userSet.add(signer) + }) + + const nextSigner = signers[currentSignerIndex + 1] + userSet.add(nextSigner) } + + setLoadingSpinnerDesc('Sending notifications') + const users = Array.from(userSet) + const promises = users.map((user) => + sendNotification(npubToHex(user)!, meta) + ) + await Promise.all(promises) + .then(() => { + toast.success('Notifications sent successfully') + setMeta(meta) + }) + .catch(() => { + toast.error('Failed to publish notifications') + }) + + setIsLoading(false) + } + + // Check if the current user is the last signer + const checkIsLastSigner = (signers: string[]): boolean => { + const usersNpub = hexToNpub(usersPubkey!) + const lastSignerIndex = signers.length - 1 + const signerIndex = signers.indexOf(usersNpub) + return signerIndex === lastSignerIndex } const handleExport = async () => { - if (!meta || !zip || !usersPubkey) return + if (Object.entries(files).length === 0 || !meta || !usersPubkey) return const usersNpub = hexToNpub(usersPubkey) if ( @@ -538,7 +672,7 @@ export const SignPage = () => { setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') - const prevSig = await getLastSignersSig() + const prevSig = getLastSignersSig() if (!prevSig) return const signedEvent = await signEventForMetaFile( @@ -561,8 +695,15 @@ export const SignPage = () => { null, 2 ) + + const zip = new JSZip() + zip.file('meta.json', stringifiedMeta) + Object.entries(files).forEach(([fileName, arrayBuffer]) => { + zip.file(`files/${fileName}`, arrayBuffer) + }) + const arrayBuffer = await zip .generateAsync({ type: 'arraybuffer', @@ -581,7 +722,8 @@ export const SignPage = () => { if (!arrayBuffer) return const blob = new Blob([arrayBuffer]) - saveAs(blob, 'exported.zip') + const unixNow = Math.floor(Date.now() / 1000) + saveAs(blob, `exported-${unixNow}.sigit.zip`) setIsLoading(false) @@ -589,7 +731,17 @@ export const SignPage = () => { } const handleExportSigit = async () => { - if (!zip) return + if (Object.entries(files).length === 0 || !meta) return + + const zip = new JSZip() + + const stringifiedMeta = JSON.stringify(meta, null, 2) + + zip.file('meta.json', stringifiedMeta) + + Object.entries(files).forEach(([fileName, arrayBuffer]) => { + zip.file(`files/${fileName}`, arrayBuffer) + }) const arrayBuffer = await zip .generateAsync({ @@ -612,11 +764,12 @@ export const SignPage = () => { setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) - const blob = new Blob([encryptedArrayBuffer]) - saveAs(blob, 'exported.sigit') - setTextToCopy(key) - setOpenCopyModel(true) + const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) + + if (!finalZipFile) return + const unixNow = Math.floor(Date.now() / 1000) + saveAs(finalZipFile, `exported-${unixNow}.sigit.zip`) } /** @@ -707,22 +860,13 @@ export const SignPage = () => { setSelectedFile(value)} /> - - {selectedFile && ( - setEncryptionKey(e.target.value)} - /> - )} - {selectedFile && encryptionKey && ( + {selectedFile && ( - - )} + {isSignerOrCreator && ( + + + + )} )} - setOpenCopyModel(false)} - title="Decryption key for Sigit file" - textToCopy={textToCopy} - /> ) } - -type DisplayMetaProps = { - meta: Meta - zip: JSZip - submittedBy: string - signers: `npub1${string}`[] - viewers: `npub1${string}`[] - creatorFileHashes: { [key: string]: string } - currentFileHashes: { [key: string]: string | null } - signedBy: `npub1${string}`[] - nextSigner?: string - getPrevSignersSig: (usersNpub: string) => string | null -} - -const DisplayMeta = ({ - meta, - zip, - submittedBy, - signers, - viewers, - creatorFileHashes, - currentFileHashes, - signedBy, - nextSigner, - getPrevSignersSig -}: DisplayMetaProps) => { - const theme = useTheme() - - const textColor = theme.palette.getContrastText( - theme.palette.background.paper - ) - - const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) - const [users, setUsers] = useState([]) - - useEffect(() => { - signers.forEach((signer) => { - const hexKey = npubToHex(signer) - setUsers((prev) => { - if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev - - return [ - ...prev, - { - pubkey: hexKey!, - role: UserRole.signer - } - ] - }) - }) - - viewers.forEach((viewer) => { - const hexKey = npubToHex(viewer) - setUsers((prev) => { - if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev - - return [ - ...prev, - { - pubkey: hexKey!, - role: UserRole.viewer - } - ] - }) - }) - }, [signers, viewers]) - - useEffect(() => { - const metadataController = new MetadataController() - - const hexKeys: string[] = [ - npubToHex(submittedBy)!, - ...users.map((user) => user.pubkey) - ] - - hexKeys.forEach((key) => { - if (!(key in metadata)) { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [key]: metadataContent - })) - } - - metadataController.on(key, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } - }) - - metadataController - .findMetadata(key) - .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) - }) - .catch((err) => { - console.error(`error occurred in finding metadata for: ${key}`, err) - }) - } - }) - }, [users, submittedBy]) - - const downloadFile = async (filename: string) => { - const arrayBuffer = await readContentOfZipEntry( - zip, - `files/${filename}`, - 'arraybuffer' - ) - if (!arrayBuffer) return - - const blob = new Blob([arrayBuffer]) - saveAs(blob, filename) - } - - return ( - Meta Info - } - > - - - Submitted By - - {(function () { - const profile = metadata[submittedBy] - return ( - - ) - })()} - - - - Files - - - {Object.entries(currentFileHashes).map(([filename, hash], index) => { - const isValidHash = creatorFileHashes[filename] === hash - - return ( - - - downloadFile(filename)}> - - - - - {filename} - - {isValidHash && ( - - - - )} - {!isValidHash && ( - - - - )} - - ) - })} - - - - - - - User - Role - Signed Status - - - - {users.map((user) => ( - - ))} - -
-
-
- ) -} - -enum PrevSignatureValidationEnum { - Pending, - Valid, - Invalid -} - -enum UserStatus { - Viewer = 'Viewer', - Awaiting = 'Awaiting Signature', - Signed = 'Signed', - Pending = 'Pending' -} - -type DisplayUserProps = { - meta: Meta - user: User - metadata: { [key: string]: ProfileMetadata } - signedBy: `npub1${string}`[] - nextSigner?: string - getPrevSignersSig: (usersNpub: string) => string | null -} - -const DisplayUser = ({ - meta, - user, - metadata, - signedBy, - nextSigner, - getPrevSignersSig -}: DisplayUserProps) => { - const theme = useTheme() - - const userMeta = metadata[user.pubkey] - const [userStatus, setUserStatus] = useState(UserStatus.Pending) - const [prevSignatureStatus, setPreviousSignatureStatus] = - useState(PrevSignatureValidationEnum.Pending) - - useEffect(() => { - if (user.role === UserRole.viewer) { - setUserStatus(UserStatus.Viewer) - return - } - - // check if user has signed the document - const usersNpub = hexToNpub(user.pubkey) - if (signedBy.includes(usersNpub)) { - setUserStatus(UserStatus.Signed) - return - } - - // check if user is the next signer - if (user.pubkey === nextSigner) { - setUserStatus(UserStatus.Awaiting) - return - } - }, [user, nextSigner, signedBy]) - - useEffect(() => { - const validatePrevSignature = async () => { - const handleNullCase = () => { - setPreviousSignatureStatus(PrevSignatureValidationEnum.Invalid) - return - } - - // get previous signers sig from the content of current signers signed event - const npub = hexToNpub(user.pubkey) - const signedEvent = await parseJson( - meta.docSignatures[npub] - ).catch((err) => { - console.log(`err in parsing the singed event for ${npub}:>> `, err) - toast.error( - err.message || - 'error occurred in parsing the signed event signature event' - ) - return null - }) - - if (!signedEvent) return handleNullCase() - - // now that we have signed event of current signer, we'll extract prevSig from its content - const parsedContent = await parseJson( - signedEvent.content - ).catch((err) => { - console.log( - `an error occurred in parsing the content of signedEvent of ${npub}`, - err - ) - toast.error( - err.message || - `an error occurred in parsing the content of signedEvent of ${npub}` - ) - return null - }) - - if (!parsedContent) return handleNullCase() - - const prevSignersSignature = getPrevSignersSig(npub) - - if (!prevSignersSignature) return handleNullCase() - - setPreviousSignatureStatus( - parsedContent.prevSig === prevSignersSignature - ? PrevSignatureValidationEnum.Valid - : PrevSignatureValidationEnum.Invalid - ) - } - - if (userStatus === UserStatus.Signed) { - validatePrevSignature() - } - }, [userStatus, meta.docSignatures, user.pubkey, getPrevSignersSig]) - - return ( - - - - - {user.role} - - - {userStatus} - {userStatus === UserStatus.Signed && ( - <> - {prevSignatureStatus === PrevSignatureValidationEnum.Valid && ( - - - - )} - {prevSignatureStatus === PrevSignatureValidationEnum.Invalid && ( - - - - )} - - )} - {userStatus === UserStatus.Awaiting && ( - - - - )} - - - - ) -} diff --git a/src/pages/sign/internal/displayMeta.tsx b/src/pages/sign/internal/displayMeta.tsx new file mode 100644 index 0000000..730347e --- /dev/null +++ b/src/pages/sign/internal/displayMeta.tsx @@ -0,0 +1,415 @@ +import { + Meta, + ProfileMetadata, + SignedEventContent, + User, + UserRole +} from '../../../types' +import { + Box, + IconButton, + List, + ListItem, + ListSubheader, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, + useTheme +} from '@mui/material' +import { + Download, + CheckCircle, + Cancel, + HourglassTop +} from '@mui/icons-material' +import saveAs from 'file-saver' +import { kinds, Event } from 'nostr-tools' +import { useState, useEffect } from 'react' +import { toast } from 'react-toastify' +import { UserComponent } from '../../../components/username' +import { MetadataController } from '../../../controllers' +import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils' +import styles from '../style.module.scss' + +type DisplayMetaProps = { + meta: Meta + files: { [filename: string]: ArrayBuffer } + submittedBy: string + signers: `npub1${string}`[] + viewers: `npub1${string}`[] + creatorFileHashes: { [key: string]: string } + currentFileHashes: { [key: string]: string | null } + signedBy: `npub1${string}`[] + nextSigner?: string + getPrevSignersSig: (usersNpub: string) => string | null +} + +export const DisplayMeta = ({ + meta, + files, + submittedBy, + signers, + viewers, + creatorFileHashes, + currentFileHashes, + signedBy, + nextSigner, + getPrevSignersSig +}: DisplayMetaProps) => { + const theme = useTheme() + + const textColor = theme.palette.getContrastText( + theme.palette.background.paper + ) + + const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( + {} + ) + const [users, setUsers] = useState([]) + + useEffect(() => { + signers.forEach((signer) => { + const hexKey = npubToHex(signer) + setUsers((prev) => { + if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev + + return [ + ...prev, + { + pubkey: hexKey!, + role: UserRole.signer + } + ] + }) + }) + + viewers.forEach((viewer) => { + const hexKey = npubToHex(viewer) + setUsers((prev) => { + if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev + + return [ + ...prev, + { + pubkey: hexKey!, + role: UserRole.viewer + } + ] + }) + }) + }, [signers, viewers]) + + useEffect(() => { + const metadataController = new MetadataController() + + const hexKeys: string[] = [ + npubToHex(submittedBy)!, + ...users.map((user) => user.pubkey) + ] + + hexKeys.forEach((key) => { + if (!(key in metadata)) { + const handleMetadataEvent = (event: Event) => { + const metadataContent = + metadataController.extractProfileMetadataContent(event) + + if (metadataContent) + setMetadata((prev) => ({ + ...prev, + [key]: metadataContent + })) + } + + metadataController.on(key, (kind: number, event: Event) => { + if (kind === kinds.Metadata) { + handleMetadataEvent(event) + } + }) + + metadataController + .findMetadata(key) + .then((metadataEvent) => { + if (metadataEvent) handleMetadataEvent(metadataEvent) + }) + .catch((err) => { + console.error(`error occurred in finding metadata for: ${key}`, err) + }) + } + }) + }, [users, submittedBy]) + + const downloadFile = async (filename: string) => { + const arrayBuffer = files[filename] + if (!arrayBuffer) return + + const blob = new Blob([arrayBuffer]) + saveAs(blob, filename) + } + + return ( + Meta Info + } + > + + + Submitted By + + {(function () { + const profile = metadata[submittedBy] + return ( + + ) + })()} + + + + Files + + + {Object.entries(currentFileHashes).map(([filename, hash], index) => { + const isValidHash = creatorFileHashes[filename] === hash + + return ( + + + downloadFile(filename)}> + + + + + {filename} + + {isValidHash && ( + + + + )} + {!isValidHash && ( + + + + )} + + ) + })} + + + + + + + User + Role + Signed Status + + + + {users.map((user) => ( + + ))} + +
+
+
+ ) +} + +enum PrevSignatureValidationEnum { + Pending, + Valid, + Invalid +} + +enum UserStatus { + Viewer = 'Viewer', + Awaiting = 'Awaiting Signature', + Signed = 'Signed', + Pending = 'Pending' +} + +type DisplayUserProps = { + meta: Meta + user: User + metadata: { [key: string]: ProfileMetadata } + signedBy: `npub1${string}`[] + nextSigner?: string + getPrevSignersSig: (usersNpub: string) => string | null +} + +const DisplayUser = ({ + meta, + user, + metadata, + signedBy, + nextSigner, + getPrevSignersSig +}: DisplayUserProps) => { + const theme = useTheme() + + const userMeta = metadata[user.pubkey] + const [userStatus, setUserStatus] = useState(UserStatus.Pending) + const [prevSignatureStatus, setPreviousSignatureStatus] = + useState(PrevSignatureValidationEnum.Pending) + + useEffect(() => { + if (user.role === UserRole.viewer) { + setUserStatus(UserStatus.Viewer) + return + } + + // check if user has signed the document + const usersNpub = hexToNpub(user.pubkey) + if (signedBy.includes(usersNpub)) { + setUserStatus(UserStatus.Signed) + return + } + + // check if user is the next signer + if (user.pubkey === nextSigner) { + setUserStatus(UserStatus.Awaiting) + return + } + }, [user, nextSigner, signedBy]) + + useEffect(() => { + const validatePrevSignature = async () => { + const handleNullCase = () => { + setPreviousSignatureStatus(PrevSignatureValidationEnum.Invalid) + return + } + + // get previous signers sig from the content of current signers signed event + const npub = hexToNpub(user.pubkey) + const signedEvent = await parseJson( + meta.docSignatures[npub] + ).catch((err) => { + console.log(`err in parsing the singed event for ${npub}:>> `, err) + toast.error( + err.message || + 'error occurred in parsing the signed event signature event' + ) + return null + }) + + if (!signedEvent) return handleNullCase() + + // now that we have signed event of current signer, we'll extract prevSig from its content + const parsedContent = await parseJson( + signedEvent.content + ).catch((err) => { + console.log( + `an error occurred in parsing the content of signedEvent of ${npub}`, + err + ) + toast.error( + err.message || + `an error occurred in parsing the content of signedEvent of ${npub}` + ) + return null + }) + + if (!parsedContent) return handleNullCase() + + const prevSignersSignature = getPrevSignersSig(npub) + + if (!prevSignersSignature) return handleNullCase() + + setPreviousSignatureStatus( + parsedContent.prevSig === prevSignersSignature + ? PrevSignatureValidationEnum.Valid + : PrevSignatureValidationEnum.Invalid + ) + } + + if (userStatus === UserStatus.Signed) { + validatePrevSignature() + } + }, [userStatus, meta.docSignatures, user.pubkey, getPrevSignersSig]) + + return ( + + + + + {user.role} + + + {userStatus} + {userStatus === UserStatus.Signed && ( + <> + {prevSignatureStatus === PrevSignatureValidationEnum.Valid && ( + + + + )} + {prevSignatureStatus === PrevSignatureValidationEnum.Invalid && ( + + + + )} + + )} + {userStatus === UserStatus.Awaiting && ( + + + + )} + + + + ) +} diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index a8e0d1f..9e10ac0 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -23,6 +23,8 @@ import { SignedEventContent } from '../../types' import { + decryptArrayBuffer, + extractZipUrlAndEncryptionKey, getHash, hexToNpub, npubToHex, @@ -32,19 +34,26 @@ import { } from '../../utils' import styles from './style.module.scss' import { Cancel, CheckCircle } from '@mui/icons-material' +import { useLocation } from 'react-router-dom' +import axios from 'axios' export const VerifyPage = () => { const theme = useTheme() - const textColor = theme.palette.getContrastText( theme.palette.background.paper ) + const location = useLocation() + /** + * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json + * meta will be received in navigation from create & home page in online mode + */ + const { uploadedZip, meta: metaInNavState } = location.state || {} + const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [selectedFile, setSelectedFile] = useState(null) - const [zip, setZip] = useState() const [meta, setMeta] = useState(null) const [submittedBy, setSubmittedBy] = useState() @@ -63,39 +72,108 @@ export const VerifyPage = () => { ) useEffect(() => { - if (zip) { - const generateCurrentFileHashes = async () => { - const fileHashes: { [key: string]: string | null } = {} - const fileNames = Object.values(zip.files) - .filter((entry) => entry.name.startsWith('files/') && !entry.dir) - .map((entry) => entry.name) + if (uploadedZip) { + setSelectedFile(uploadedZip) + } else if (metaInNavState) { + const processSigit = async () => { + setIsLoading(true) + setLoadingSpinnerDesc('Extracting zipUrl and encryption key from meta') - // generate hashes for all entries in files folder of zipArchive - // these hashes can be used to verify the originality of files - for (const fileName of fileNames) { - const arrayBuffer = await readContentOfZipEntry( - zip, - fileName, - 'arraybuffer' - ) - - if (arrayBuffer) { - const hash = await getHash(arrayBuffer) - - if (hash) { - fileHashes[fileName.replace(/^files\//, '')] = hash - } - } else { - fileHashes[fileName.replace(/^files\//, '')] = null - } + const res = await extractZipUrlAndEncryptionKey(metaInNavState) + if (!res) { + setIsLoading(false) + return } - setCurrentFileHashes(fileHashes) + const { + zipUrl, + encryptionKey, + createSignatureEvent, + createSignatureContent + } = res + + setLoadingSpinnerDesc('Fetching file from file server') + axios + .get(zipUrl, { + responseType: 'arraybuffer' + }) + .then(async (res) => { + const fileName = zipUrl.split('/').pop() + const file = new File([res.data], fileName!) + + const encryptedArrayBuffer = await file.arrayBuffer() + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer, + encryptionKey + ).catch((err) => { + console.log('err in decryption:>> ', err) + toast.error( + err.message || 'An error occurred in decrypting file.' + ) + return null + }) + + if (arrayBuffer) { + const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => { + console.log('err in loading zip file :>> ', err) + toast.error( + err.message || 'An error occurred in loading zip file.' + ) + return null + }) + + if (!zip) return + + const fileHashes: { [key: string]: string | null } = {} + const fileNames = Object.values(zip.files).map( + (entry) => entry.name + ) + + // generate hashes for all entries in files folder of zipArchive + // these hashes can be used to verify the originality of files + for (const fileName of fileNames) { + const arrayBuffer = await readContentOfZipEntry( + zip, + fileName, + 'arraybuffer' + ) + + if (arrayBuffer) { + const hash = await getHash(arrayBuffer) + + if (hash) { + fileHashes[fileName.replace(/^files\//, '')] = hash + } + } else { + fileHashes[fileName.replace(/^files\//, '')] = null + } + } + + setCurrentFileHashes(fileHashes) + + setSigners(createSignatureContent.signers) + setViewers(createSignatureContent.viewers) + setCreatorFileHashes(createSignatureContent.fileHashes) + setSubmittedBy(createSignatureEvent.pubkey) + + setMeta(metaInNavState) + setIsLoading(false) + } + }) + .catch((err) => { + console.error(`error occurred in getting file from ${zipUrl}`, err) + toast.error( + err.message || `error occurred in getting file from ${zipUrl}` + ) + }) + .finally(() => { + setIsLoading(false) + }) } - generateCurrentFileHashes() + processSigit() } - }, [zip]) + }, [uploadedZip, metaInNavState]) useEffect(() => { if (submittedBy) { @@ -150,7 +228,34 @@ export const VerifyPage = () => { }) if (!zip) return - setZip(zip) + + const fileHashes: { [key: string]: string | null } = {} + const fileNames = Object.values(zip.files) + .filter((entry) => entry.name.startsWith('files/') && !entry.dir) + .map((entry) => entry.name) + + // generate hashes for all entries in files folder of zipArchive + // these hashes can be used to verify the originality of files + for (const fileName of fileNames) { + const arrayBuffer = await readContentOfZipEntry( + zip, + fileName, + 'arraybuffer' + ) + + if (arrayBuffer) { + const hash = await getHash(arrayBuffer) + + if (hash) { + fileHashes[fileName.replace(/^files\//, '')] = hash + } + } else { + fileHashes[fileName.replace(/^files\//, '')] = null + } + } + + console.log('fileHashes :>> ', fileHashes) + setCurrentFileHashes(fileHashes) setLoadingSpinnerDesc('Parsing meta.json') @@ -364,7 +469,7 @@ export const VerifyPage = () => { onChange={(value) => setSelectedFile(value)} InputProps={{ inputProps: { - accept: '.zip' + accept: '.sigit.zip' } }} /> diff --git a/src/services/cache/index.ts b/src/services/cache/index.ts index 3fc8de0..957e45b 100644 --- a/src/services/cache/index.ts +++ b/src/services/cache/index.ts @@ -1,21 +1,31 @@ import { IDBPDatabase, openDB } from 'idb' import { Event } from 'nostr-tools' -import { CachedMetadataEvent } from '../../types' -import { SchemaV1 } from './schema' +import { CachedEvent } from '../../types' +import { SchemaV2 } from './schema' class LocalCache { // Static property to hold the single instance of LocalCache private static instance: LocalCache | null = null - private db!: IDBPDatabase + private db!: IDBPDatabase // Private constructor to prevent direct instantiation private constructor() {} // Method to initialize the database private async init() { - this.db = await openDB('sigit-cache', 1, { - upgrade(db) { - db.createObjectStore('userMetadata', { keyPath: 'event.pubkey' }) + this.db = await openDB('sigit-cache', 2, { + upgrade(db, oldVersion) { + if (oldVersion < 1) { + db.createObjectStore('userMetadata', { keyPath: 'event.pubkey' }) + } + + if (oldVersion < 2) { + const v6 = db as unknown as IDBPDatabase + + v6.createObjectStore('userRelayListMetadata', { + keyPath: 'event.pubkey' + }) + } } }) } @@ -37,9 +47,7 @@ class LocalCache { } // Method to get user metadata by key - public async getUserMetadata( - key: string - ): Promise { + public async getUserMetadata(key: string): Promise { const data = await this.db.get('userMetadata', key) return data || null } @@ -49,6 +57,21 @@ class LocalCache { await this.db.delete('userMetadata', key) } + public async addUserRelayListMetadata(event: Event) { + await this.db.put('userRelayListMetadata', { event, cachedAt: Date.now() }) + } + + public async getUserRelayListMetadata( + key: string + ): Promise { + const data = await this.db.get('userRelayListMetadata', key) + return data || null + } + + public async deleteUserRelayListMetadata(key: string) { + await this.db.delete('userRelayListMetadata', key) + } + // Method to clear cache data public async clearCacheData() { // Clear the 'userMetadata' store in the IndexedDB database diff --git a/src/services/cache/schema.ts b/src/services/cache/schema.ts index 869aabf..bc21956 100644 --- a/src/services/cache/schema.ts +++ b/src/services/cache/schema.ts @@ -1,9 +1,16 @@ import { DBSchema } from 'idb' -import { CachedMetadataEvent } from '../../types' +import { CachedEvent } from '../../types' export interface SchemaV1 extends DBSchema { userMetadata: { key: string - value: CachedMetadataEvent + value: CachedEvent + } +} + +export interface SchemaV2 extends SchemaV1 { + userRelayListMetadata: { + key: string + value: CachedEvent } } diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts index 18b4063..7a09430 100644 --- a/src/store/actionTypes.ts +++ b/src/store/actionTypes.ts @@ -17,3 +17,6 @@ export const SET_RELAY_INFO = 'SET_RELAY_INFO' export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED' export const SET_MOST_POPULAR_RELAYS = 'SET_MOST_POPULAR_RELAYS' export const SET_RELAY_CONNECTION_STATUS = 'SET_RELAY_CONNECTION_STATUS' + +export const UPDATE_USER_APP_DATA = 'UPDATE_USER_APP_DATA' +export const UPDATE_PROCESSED_GIFT_WRAPS = 'UPDATE_PROCESSED_GIFT_WRAPS' diff --git a/src/store/actions.ts b/src/store/actions.ts index a101199..aee594f 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -4,6 +4,7 @@ import { State } from './rootReducer' export * from './auth/action' export * from './metadata/action' export * from './relays/action' +export * from './userAppData/action' export const restoreState = (payload: State) => { return { diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index 517291c..b37a3cd 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -1,25 +1,29 @@ import { Event } from 'nostr-tools' import { combineReducers } from 'redux' +import { UserAppData } from '../types' +import * as ActionTypes from './actionTypes' import authReducer from './auth/reducer' import { AuthState } from './auth/types' import metadataReducer from './metadata/reducer' -import userRobotImageReducer from './userRobotImage/reducer' -import { RelaysState } from './relays/types' import relaysReducer from './relays/reducer' -import * as ActionTypes from './actionTypes' +import { RelaysState } from './relays/types' +import UserAppDataReducer from './userAppData/reducer' +import userRobotImageReducer from './userRobotImage/reducer' export interface State { auth: AuthState metadata?: Event userRobotImage?: string relays: RelaysState + userAppData?: UserAppData } export const appReducer = combineReducers({ auth: authReducer, metadata: metadataReducer, userRobotImage: userRobotImageReducer, - relays: relaysReducer + relays: relaysReducer, + userAppData: UserAppDataReducer }) // FIXME: define types diff --git a/src/store/userAppData/action.ts b/src/store/userAppData/action.ts new file mode 100644 index 0000000..23bf9a1 --- /dev/null +++ b/src/store/userAppData/action.ts @@ -0,0 +1,15 @@ +import { UserAppData } from '../../types' +import * as ActionTypes from '../actionTypes' +import { UpdateProcessedGiftWraps, UpdateUserAppData } from './types' + +export const updateUserAppData = (payload: UserAppData): UpdateUserAppData => ({ + type: ActionTypes.UPDATE_USER_APP_DATA, + payload +}) + +export const updateProcessedGiftWraps = ( + payload: string[] +): UpdateProcessedGiftWraps => ({ + type: ActionTypes.UPDATE_PROCESSED_GIFT_WRAPS, + payload +}) diff --git a/src/store/userAppData/reducer.ts b/src/store/userAppData/reducer.ts new file mode 100644 index 0000000..22c7cb0 --- /dev/null +++ b/src/store/userAppData/reducer.ts @@ -0,0 +1,35 @@ +import { UserAppData } from '../../types' +import * as ActionTypes from '../actionTypes' +import { UserAppDataDispatchTypes } from './types' + +const initialState: UserAppData = { + sigits: {}, + processedGiftWraps: [], + blossomUrls: [] +} + +const reducer = ( + state = initialState, + action: UserAppDataDispatchTypes +): UserAppData | null => { + switch (action.type) { + case ActionTypes.UPDATE_USER_APP_DATA: + return { + ...action.payload + } + + case ActionTypes.UPDATE_PROCESSED_GIFT_WRAPS: + return { + ...state, + processedGiftWraps: action.payload + } + + case ActionTypes.RESTORE_STATE: + return action.payload.userAppData || null + + default: + return state + } +} + +export default reducer diff --git a/src/store/userAppData/types.ts b/src/store/userAppData/types.ts new file mode 100644 index 0000000..07c207f --- /dev/null +++ b/src/store/userAppData/types.ts @@ -0,0 +1,18 @@ +import { UserAppData } from '../../types' +import * as ActionTypes from '../actionTypes' +import { RestoreState } from '../actions' + +export interface UpdateUserAppData { + type: typeof ActionTypes.UPDATE_USER_APP_DATA + payload: UserAppData +} + +export interface UpdateProcessedGiftWraps { + type: typeof ActionTypes.UPDATE_PROCESSED_GIFT_WRAPS + payload: string[] +} + +export type UserAppDataDispatchTypes = + | UpdateUserAppData + | UpdateProcessedGiftWraps + | RestoreState diff --git a/src/types/cache.ts b/src/types/cache.ts index 2a0edcd..c086d44 100644 --- a/src/types/cache.ts +++ b/src/types/cache.ts @@ -1,6 +1,6 @@ import { Event } from 'nostr-tools' -export interface CachedMetadataEvent { +export interface CachedEvent { event: Event cachedAt: number } diff --git a/src/types/core.ts b/src/types/core.ts index cff29b5..98698c6 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -1,3 +1,5 @@ +import { Keys } from '../store/auth/types' + export enum UserRole { signer = 'Signer', viewer = 'Viewer' @@ -9,18 +11,33 @@ export interface User { } export interface Meta { - title: string + modifiedAt: number createSignature: string docSignatures: { [key: `npub1${string}`]: string } exportSignature?: string + keys?: { sender: string; keys: { [user: `npub1${string}`]: string } } } export interface CreateSignatureEventContent { signers: `npub1${string}`[] viewers: `npub1${string}`[] fileHashes: { [key: string]: string } + title: string + zipUrl: string } export interface SignedEventContent { prevSig: string } + +export interface Sigit { + fileUrl: string + meta: Meta +} + +export interface UserAppData { + sigits: { [key: string]: Meta } // key will be id of create signature + processedGiftWraps: string[] // an array of ids of processed gift wrapped events + keyPair?: Keys // this key pair is used for blossom requests authentication + blossomUrls: string[] // array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom +} diff --git a/src/types/system/index.d.ts b/src/types/system/index.d.ts new file mode 100644 index 0000000..81eb877 --- /dev/null +++ b/src/types/system/index.d.ts @@ -0,0 +1,7 @@ +import type { WindowNostr } from 'nostr-tools/nip07' + +declare global { + interface Window { + nostr?: WindowNostr + } +} diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 03b2fa6..a368e06 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,8 +1,21 @@ import axios from 'axios' -import { EventTemplate } from 'nostr-tools' -import { MetadataController, NostrController } from '../controllers' +import { + Event, + EventTemplate, + finalizeEvent, + generateSecretKey, + getPublicKey, + nip04, + verifyEvent +} from 'nostr-tools' import { toast } from 'react-toastify' -import { appPrivateRoutes } from '../routes' +import { NostrController } from '../controllers' +import { AuthState } from '../store/auth/types' +import store from '../store/store' +import { CreateSignatureEventContent, Meta } from '../types' +import { hexToNpub, now } from './nostr' +import { parseJson } from './string' +import { hexToBytes } from '@noble/hashes/utils' /** * Uploads a file to a file storage service. @@ -10,18 +23,7 @@ import { appPrivateRoutes } from '../routes' * @param nostrController The NostrController instance for handling authentication. * @returns The URL of the uploaded file. */ -export const uploadToFileStorage = async ( - blob: Blob, - nostrController: NostrController -) => { - // Get the current timestamp in seconds - const unixNow = Math.floor(Date.now() / 1000) - - // Create a File object with the Blob data - const file = new File([blob], `compressed-${unixNow}.sigit`, { - type: 'application/sigit' - }) - +export const uploadToFileStorage = async (file: File) => { // Define event metadata for authorization const event: EventTemplate = { kind: 24242, @@ -29,14 +31,21 @@ export const uploadToFileStorage = async ( created_at: Math.floor(Date.now() / 1000), tags: [ ['t', 'upload'], - ['expiration', String(unixNow + 60 * 5)], // Set expiration time to 5 minutes from now + ['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now ['name', file.name], ['size', String(file.size)] ] } - // Sign the authorization event using the NostrController - const authEvent = await nostrController.signEvent(event) + const key = store.getState().userAppData?.keyPair?.private + if (!key) { + throw new Error( + 'Key to interact with blossom server is not defined in user app data' + ) + } + + // Sign the authorization event using the dedicated key stored in user app data + const authEvent = finalizeEvent(event, hexToBytes(key)) // URL of the file storage service const FILE_STORAGE_URL = 'https://blossom.sigit.io' // REFACTOR: should be an env @@ -53,128 +62,6 @@ export const uploadToFileStorage = async ( return response.data.url as string } -/** - * Sends a Direct Message (DM) to a recipient, encrypting the content and handling authentication. - * @param fileUrl The URL of the encrypted zip file to be included in the DM. - * @param encryptionKey The encryption key used to decrypt the zip file to be included in the DM. - * @param pubkey The public key of the recipient. - * @param nostrController The NostrController instance for handling authentication and encryption. - * @param isSigner Boolean indicating whether the recipient is a signer or viewer. - * @param setAuthUrl Function to set the authentication URL in the component state. - */ -export const sendDM = async ( - fileUrl: string, - encryptionKey: string, - pubkey: string, - nostrController: NostrController, - isSigner: boolean, - setAuthUrl: (value: React.SetStateAction) => void -) => { - // Construct the content of the DM - const initialLine = isSigner - ? 'Your signature is requested on the document below!' - : 'You have received a signed document.' - - const decryptionUrl = `${window.location.origin}/#${ - appPrivateRoutes.sign - }?file=${encodeURIComponent(fileUrl)}&key=${encodeURIComponent( - encryptionKey - )}` - - const content = `${initialLine}\n\n${decryptionUrl}` - - // Set up event listener for authentication event - nostrController.on('nsecbunker-auth', (url) => { - setAuthUrl(url) - }) - - // Set up timeout promise to handle encryption timeout - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Timeout occurred')) - }, 60000) // Timeout duration = 60 seconds - }) - - // Encrypt the DM content, with timeout - const encrypted = await Promise.race([ - nostrController.nip04Encrypt(pubkey, content), - timeoutPromise - ]) - .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) // Clear authentication URL - }) - - // Return if encryption failed - if (!encrypted) return - - // Construct event metadata for the DM - const event: EventTemplate = { - kind: 4, // DM event type - content: encrypted, // Encrypted DM content - created_at: Math.floor(Date.now() / 1000), // Current timestamp - tags: [['p', pubkey]] // Tag with recipient's public key - } - - // Sign the DM event - 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 - }) - - // Return if event signing failed - if (!signedEvent) return - - // Get relay list metadata - const metadataController = new MetadataController() - const relaySet = await metadataController - .findRelayListMetadata(pubkey) - .catch((err) => { - toast.error( - err.message || 'An error occurred while finding relay list metadata' - ) - return null - }) - - // Return if metadata retrieval failed - if (!relaySet) return - - // Ensure relay list is not empty - if (relaySet.read.length === 0) { - toast.error('No relay found for publishing encrypted DM') - return - } - - // Publish the signed DM event to the recipient's read relays - await nostrController - .publishEvent(signedEvent, relaySet.read) - .then((relays) => { - toast.success(`Encrypted DM sent on: ${relays.join('\n')}`) - }) - .catch((errResults) => { - console.log('err :>> ', errResults) - toast.error('An error occurred while publishing DM') - - errResults.forEach((errResult: any) => { - toast.error( - `Publishing to ${errResult.relay} caused the following error: ${errResult.error}` - ) - }) - - return null - }) -} - /** * Signs an event for a meta.json file. * @param content contains content for event. @@ -192,7 +79,7 @@ export const signEventForMetaFile = async ( kind: 27235, // Event type for meta file content: content, // content for event created_at: Math.floor(Date.now() / 1000), // Current timestamp - tags: [] + tags: [['-']] // For understanding why "-" tag is used here see: https://github.com/nostr-protocol/nips/blob/protected-events-tag/70.md } // Sign the event @@ -205,3 +92,175 @@ export const signEventForMetaFile = async ( return signedEvent // Return the signed event } + +/** + * Generates the content for keys.json file. + * + * @param users - An array of public keys. + * @param key - The key that will be encrypted for each user. + * @returns A promise that resolves to a JSON string containing the sender's public key and encrypted keys, or null if an error occurs. + */ +export const generateKeysFile = async ( + users: string[], + key: string +): Promise => { + // Generate a random private key to act as the sender + const privateKey = generateSecretKey() + + // Calculate the required length to be a multiple of 10 + const requiredLength = Math.ceil(users.length / 10) * 10 + const additionalKeysCount = requiredLength - users.length + + if (additionalKeysCount > 0) { + // generate random public keys to make the keys array multiple of 10 + const additionalPubkeys = Array.from({ length: additionalKeysCount }, () => + getPublicKey(generateSecretKey()) + ) + + users.push(...additionalPubkeys) + } + + // Encrypt the key for each user's public key + const promises = users.map((pubkey) => nip04.encrypt(privateKey, pubkey, key)) + + // Wait for all encryption promises to resolve + const keys = await Promise.all(promises).catch((err) => { + console.log('Error while generating keys :>> ', err) + toast.error(err.message || 'An error occurred while generating key') + return null + }) + + // If any encryption promise failed, return null + if (!keys) return null + + try { + // Return a JSON string containing the sender's public key and encrypted keys + return JSON.stringify({ sender: getPublicKey(privateKey), keys }) + } catch (error) { + // Return null if an error occurs during JSON stringification + return null + } +} + +/** + * Encrypt decryption key for each users pubkey. + * + * @param pubkeys - An array of public keys. + * @param key - The key that will be encrypted for each user. + * @returns A promise that resolves to a JSON object containing a random pubkey as sender and encrypted keys for each user, or null if an error occurs. + */ +export const generateKeys = async ( + pubkeys: string[], + key: string +): Promise<{ + sender: string + keys: { [user: `npub1${string}`]: string } +} | null> => { + // Generate a random private key to act as the sender + const privateKey = generateSecretKey() + + const keys: { [user: `npub1${string}`]: string } = {} + + // Encrypt the key for each user's public key + for (const pubkey of pubkeys) { + const npub = hexToNpub(pubkey) + + const encryptedKey = await nip04 + .encrypt(privateKey, pubkey, key) + .catch((err) => { + console.log(`An error occurred in encrypting key for ${npub}`, err) + toast.error('An error occurred in key encryption') + return null + }) + + if (!encryptedKey) return null + + keys[npub] = encryptedKey + } + + return { sender: getPublicKey(privateKey), keys } +} + +/** + * Function to extract the ZIP URL and encryption key from the provided metadata. + * @param meta - The metadata object containing the create signature and encryption keys. + * @returns A promise that resolves to an object containing the create signature event, + * create signature content, ZIP URL, and decrypted encryption key. + */ +export const extractZipUrlAndEncryptionKey = async (meta: Meta) => { + // Parse the create signature event from the metadata + const createSignatureEvent = await parseJson( + meta.createSignature + ).catch((err) => { + // Log and display an error message if parsing fails + console.log('err in parsing the createSignature event:>> ', err) + toast.error( + err.message || 'error occurred in parsing the create signature event' + ) + return null + }) + + // Return null if the create signature event could not be parsed + if (!createSignatureEvent) return null + + // Verify the validity of the create signature event + const isValidCreateSignature = verifyEvent(createSignatureEvent) + if (!isValidCreateSignature) { + toast.error('Create signature is invalid') + return null + } + + // Parse the content of the create signature event + const createSignatureContent = await parseJson( + createSignatureEvent.content + ).catch((err) => { + // Log and display an error message if parsing fails + console.log(`err in parsing the createSignature event's content :>> `, err) + toast.error( + `error occurred in parsing the create signature event's content` + ) + return null + }) + + // Return null if the create signature content could not be parsed + if (!createSignatureContent) return null + + // Extract the ZIP URL from the create signature content + const zipUrl = createSignatureContent.zipUrl + + // Retrieve the user's public key from the state + const usersPubkey = (store.getState().auth as AuthState).usersPubkey! + const usersNpub = hexToNpub(usersPubkey) + + // Return null if the metadata does not contain keys + if (!meta.keys) return null + + const { sender, keys } = meta.keys + + // Check if the user's public key is in the keys object + if (usersNpub in keys) { + // Instantiate the NostrController to decrypt the encryption key + const nostrController = NostrController.getInstance() + const decrypted = await nostrController + .nip04Decrypt(sender, keys[usersNpub]) + .catch((err) => { + // Log and display an error message if decryption fails + console.log('An error occurred in decrypting encryption key', err) + toast.error('An error occurred in decrypting encryption key') + return null + }) + + // Return null if the encryption key could not be decrypted + if (!decrypted) return null + + // Return the parsed and decrypted data + return { + createSignatureEvent, + createSignatureContent, + zipUrl, + encryptionKey: decrypted + } + } + + return null +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index 48d369e..ff38faa 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -1,7 +1,35 @@ -import { nip19, verifyEvent } from 'nostr-tools' -import { SignedEvent } from '../types' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import axios from 'axios' +import _ from 'lodash' +import { + Event, + EventTemplate, + Filter, + SimplePool, + UnsignedEvent, + finalizeEvent, + generateSecretKey, + getEventHash, + getPublicKey, + kinds, + nip19, + nip44, + verifyEvent +} from 'nostr-tools' +import { toast } from 'react-toastify' import { NIP05_REGEX } from '../constants' +import { MetadataController, NostrController } from '../controllers' +import { + updateProcessedGiftWraps, + updateUserAppData as updateUserAppDataAction +} from '../store/actions' +import { AuthState, Keys } from '../store/auth/types' +import { RelaysState } from '../store/relays/types' +import store from '../store/store' +import { Meta, SignedEvent, UserAppData } from '../types' +import { getHash } from './hash' +import { parseJson, removeLeadingSlash } from './string' +import { timeout } from './utils' /** * @param hexKey hex private or public key @@ -181,3 +209,710 @@ export const getRoboHashPicture = ( const npub = hexToNpub(pubkey) return `https://robohash.org/${npub}.png?set=set${set}` } + +export const now = () => Math.round(Date.now() / 1000) + +/** + * Generate nip44 conversation key + * @param privateKey + * @param publicKey + * @returns + */ +export const nip44ConversationKey = ( + privateKey: Uint8Array, + publicKey: string +) => nip44.v2.utils.getConversationKey(privateKey, publicKey) + +export const nip44Encrypt = ( + data: UnsignedEvent, + privateKey: Uint8Array, + publicKey: string +) => + nip44.v2.encrypt( + JSON.stringify(data), + nip44ConversationKey(privateKey, publicKey) + ) + +export const nip44Decrypt = (data: Event, privateKey: Uint8Array) => + JSON.parse( + nip44.v2.decrypt( + data.content, + nip44ConversationKey(privateKey, data.pubkey) + ) + ) + +// Function to count leading zero bits +export const countLeadingZeroes = (hex: string) => { + let count = 0 + + for (let i = 0; i < hex.length; i++) { + const nibble = parseInt(hex[i], 16) + if (nibble === 0) { + count += 4 + } else { + count += Math.clz32(nibble) - 28 + break + } + } + + return count +} + +/** + * Function to create a wrapped event with PoW + * @param event Original event to be wrapped + * @param receiver Public key of the receiver + * @param difficulty PoW difficulty level (default is 20) + * @returns + */ +// +export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { + // Generate a random secret key and its corresponding public key + const randomKey = generateSecretKey() + const pubkey = getPublicKey(randomKey) + + // Encrypt the event content using nip44 encryption + const content = nip44Encrypt(unsignedEvent, randomKey, receiver) + + // Initialize nonce and leadingZeroes for PoW calculation + let nonce = 0 + let leadingZeroes = 0 + const difficulty = Math.floor(Math.random() * 10) + 5 // random number between 5 & 10 + + // Loop until a valid PoW hash is found + // eslint-disable-next-line no-constant-condition + while (true) { + // Create an unsigned event with the necessary fields + const event: UnsignedEvent = { + kind: 1059, // Event kind + content, // Encrypted content + pubkey, // Public key of the creator + created_at: now(), // Current timestamp + tags: [ + // Tags including receiver and nonce + ['p', receiver], + ['nonce', nonce.toString(), difficulty.toString()] + ] + } + + // Calculate the SHA-256 hash of the unsigned event + const hash = getEventHash(event) + + // Count the number of leading zero bits in the hash + leadingZeroes = countLeadingZeroes(hash) + + // Check if the leading zero bits meet the required difficulty + if (leadingZeroes >= difficulty) { + // Finalize the event (sign it) and return the result + return finalizeEvent(event, randomKey) + } + + // Increment the nonce for the next iteration + nonce++ + } +} + +export const getUsersAppData = async (): Promise => { + const relays: string[] = [] + + const usersPubkey = (store.getState().auth as AuthState).usersPubkey! + const relayMap = store.getState().relays?.map + + const nostrController = NostrController.getInstance() + + // check if relaysMap in redux store is undefined + if (!relayMap) { + const metadataController = new MetadataController() + const relaySet = await metadataController + .findRelayListMetadata(usersPubkey) + .catch((err) => { + console.log( + `An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`, + err + ) + return null + }) + + // Return if metadata retrieval failed + if (!relaySet) return null + + // Ensure relay list is not empty + if (relaySet.write.length === 0) return null + + relays.push(...relaySet.write) + } else { + // filter write relays from user's relayMap stored in redux store + const writeRelays = Object.keys(relayMap).filter( + (key) => relayMap[key].write + ) + + relays.push(...writeRelays) + } + + // generate an identifier for user's nip78 + const hash = await getHash('938' + usersPubkey) + if (!hash) return null + + const filter: Filter = { + kinds: [kinds.Application], + '#d': [hash] + } + + const encryptedContent = await nostrController + .getEvent(filter, relays) + .then((event) => { + if (event) return event.content + + // if person is using sigit for first time its possible that event is null + // so we'll return empty stringified object + return '{}' + }) + .catch((err) => { + console.log(`An error occurred in finding kind 30078 event`, err) + toast.error( + 'An error occurred in finding kind 30078 event for data storage' + ) + return null + }) + + if (!encryptedContent) return null + + if (encryptedContent === '{}') { + const secret = generateSecretKey() + const pubKey = getPublicKey(secret) + + return { + sigits: {}, + processedGiftWraps: [], + blossomUrls: [], + keyPair: { + private: bytesToHex(secret), + public: pubKey + } + } + } + + const decrypted = await nostrController + .nip04Decrypt(usersPubkey, encryptedContent) + .catch((err) => { + console.log('An error occurred while decrypting app data', err) + toast.error('An error occurred while decrypting app data') + return null + }) + + if (!decrypted) return null + + const parsedContent = await parseJson<{ + blossomUrls: string[] + keyPair: Keys + }>(decrypted).catch((err) => { + console.log( + 'An error occurred in parsing the content of kind 30078 event', + err + ) + toast.error('An error occurred in parsing the content of kind 30078 event') + return null + }) + + if (!parsedContent) return null + + const { blossomUrls, keyPair } = parsedContent + + if (blossomUrls.length === 0) return null + + const dataFromBlossom = await getUserAppDataFromBlossom( + blossomUrls[0], + keyPair.private + ) + + if (!dataFromBlossom) return null + + const { sigits, processedGiftWraps } = dataFromBlossom + + return { + blossomUrls, + keyPair, + sigits, + processedGiftWraps + } +} + +export const updateUsersAppData = async (meta: Meta) => { + const appData = store.getState().userAppData + if (!appData || !appData.keyPair) return null + + const sigits = _.cloneDeep(appData.sigits) + + const createSignatureEvent = await parseJson( + meta.createSignature + ).catch((err) => { + console.log('err in parsing the createSignature event:>> ', err) + toast.error( + err.message || 'error occurred in parsing the create signature event' + ) + return null + }) + + if (!createSignatureEvent) return null + + const id = createSignatureEvent.id + let isUpdated = false + + // check if sigit already exists + if (id in sigits) { + // update meta only if incoming meta is more recent + // than already existing one + const existingMeta = sigits[id] + if (existingMeta.modifiedAt < meta.modifiedAt) { + sigits[id] = meta + isUpdated = true + } + } else { + sigits[id] = meta + isUpdated = true + } + + if (!isUpdated) return null + + const blossomUrls = [...appData.blossomUrls] + + const newBlossomUrl = await uploadUserAppDataToBlossom( + sigits, + appData.processedGiftWraps, + appData.keyPair.private + ).catch((err) => { + console.log( + 'An error occurred in uploading user app data file to blossom server', + err + ) + toast.error( + 'An error occurred in uploading user app data file to blossom server' + ) + return null + }) + + if (!newBlossomUrl) return null + + // insert new blossom url at the start of the array + blossomUrls.unshift(newBlossomUrl) + + // only keep last 10 blossom urls, delete older ones + if (blossomUrls.length > 10) { + const filesToDelete = blossomUrls.splice(10) + filesToDelete.forEach((url) => { + deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { + console.log( + 'An error occurred in removing old file of user app data from blossom server', + err + ) + }) + }) + } + + const usersPubkey = (store.getState().auth as AuthState).usersPubkey! + + // encrypt content for storing in kind 30078 event + const nostrController = NostrController.getInstance() + const encryptedContent = await nostrController + .nip04Encrypt( + usersPubkey, + JSON.stringify({ + blossomUrls, + keyPair: appData.keyPair + }) + ) + .catch((err) => { + console.log( + 'An error occurred in encryption of content for app data', + err + ) + toast.error( + err.message || 'An error occurred in encryption of content for app data' + ) + return null + }) + + if (!encryptedContent) return null + + // generate the identifier for user's appData event + const hash = await getHash('938' + usersPubkey) + if (!hash) return null + + const updatedEvent: UnsignedEvent = { + kind: kinds.Application, + pubkey: usersPubkey!, + created_at: now(), + tags: [['d', hash]], + content: encryptedContent + } + + const signedEvent = await nostrController + .signEvent(updatedEvent) + .catch((err) => { + console.log('An error occurred in signing event', err) + toast.error(err.message || 'An error occurred in signing event') + return null + }) + + if (!signedEvent) return null + + const relayMap = (store.getState().relays as RelaysState).map! + const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write) + + console.log(`publishing event kind: ${kinds.Application}`) + const publishResult = await Promise.race([ + nostrController.publishEvent(signedEvent, writeRelays), + timeout(1000 * 30) + ]).catch((err) => { + console.log('err :>> ', err) + if (err.message === 'Timeout') { + toast.error('Timeout occurred in publishing updated app data') + } else if (Array.isArray(err)) { + err.forEach((errResult) => { + toast.error( + `Publishing to ${errResult.relay} caused the following error: ${errResult.error}` + ) + }) + } else { + toast.error( + 'An unexpected error occurred in publishing updated app data ' + ) + } + + return null + }) + + if (!publishResult) return null + + // update redux store + store.dispatch( + updateUserAppDataAction({ + sigits, + blossomUrls, + processedGiftWraps: [...appData.processedGiftWraps], + keyPair: { + ...appData.keyPair + } + }) + ) + + return signedEvent +} + +const deleteBlossomFile = async (url: string, privateKey: string) => { + const pathname = new URL(url).pathname + const hash = removeLeadingSlash(pathname) + + // Define event metadata for authorization + const event: EventTemplate = { + kind: 24242, + content: 'Authorize Upload', + created_at: now(), + tags: [ + ['t', 'delete'], + ['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now + ['x', hash] + ] + } + + const authEvent = finalizeEvent(event, hexToBytes(privateKey)) + + // delete the file stored on file storage service using Axios + const response = await axios.delete(url, { + headers: { + Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header + } + }) + + console.log('response.data :>> ', response.data) +} + +/** + * Function to upload user application data to the Blossom server. + * @param sigits - An object containing metadata for the user application data. + * @param processedGiftWraps - An array of processed gift wrap IDs. + * @param privateKey - The private key used for encryption. + * @returns A promise that resolves to the URL of the uploaded file. + */ +const uploadUserAppDataToBlossom = async ( + sigits: { [key: string]: Meta }, + processedGiftWraps: string[], + privateKey: string +) => { + // Create an object containing the sigits and processed gift wraps + const obj = { + sigits, + processedGiftWraps + } + // Convert the object to a JSON string + const stringified = JSON.stringify(obj) + + // Convert the private key from hex to bytes + const secretKey = hexToBytes(privateKey) + // Encrypt the JSON string using the secret key + const encrypted = nip44.v2.encrypt( + stringified, + nip44ConversationKey(secretKey, getPublicKey(secretKey)) + ) + + // Create a blob from the encrypted data + const blob = new Blob([encrypted], { type: 'application/octet-stream' }) + // Create a file from the blob + const file = new File([blob], 'encrypted.txt', { + type: 'application/octet-stream' + }) + + // Define event metadata for authorization + const event: EventTemplate = { + kind: 24242, + content: 'Authorize Upload', + created_at: now(), + tags: [ + ['t', 'upload'], + ['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now + ['name', file.name], + ['size', String(file.size)] + ] + } + + // Finalize the event with the private key + const authEvent = finalizeEvent(event, hexToBytes(privateKey)) + + // URL of the file storage service + const FILE_STORAGE_URL = 'https://blossom.sigit.io' + + // Upload the file to the file storage service using Axios + const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, { + headers: { + Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header + } + }) + + // Return the URL of the uploaded file + return response.data.url as string +} + +/** + * Function to retrieve and decrypt user application data from Blossom server. + * @param url - The URL to fetch the encrypted data from. + * @param privateKey - The private key used for decryption. + * @returns A promise that resolves to the decrypted and parsed user application data. + */ +const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { + // Initialize errorCode to track HTTP error codes + let errorCode = 0 + + // Fetch the encrypted data from the provided URL + const encrypted = await axios + .get(url, { + responseType: 'blob' // Expect a blob response + }) + .then(async (res) => { + // Convert the blob response to a File object + const file = new File([res.data], 'encrypted.txt') + // Read the text content from the file + const text = await file.text() + return text + }) + .catch((err) => { + // Log and display an error message if the request fails + console.error(`error occurred in getting file from ${url}`, err) + toast.error(err.message || `error occurred in getting file from ${url}`) + + // Set errorCode to the HTTP status code if available + if (err.request) { + const { status } = err.request + errorCode = status + } + + return null + }) + + // Return a default value if the requested resource is not found (404) + if (errorCode === 404) { + return { + sigits: {}, + processedGiftWraps: [] + } + } + + // Return null if the encrypted data could not be retrieved + if (!encrypted) return null + + // Convert the private key from hex to bytes + const secret = hexToBytes(privateKey) + // Get the public key corresponding to the private key + const pubkey = getPublicKey(secret) + + // Decrypt the encrypted data using the secret and public key + const decrypted = nip44.v2.decrypt( + encrypted, + nip44ConversationKey(secret, pubkey) + ) + + // Parse the decrypted JSON content + const parsedContent = await parseJson<{ + sigits: { [key: string]: Meta } + processedGiftWraps: string[] + }>(decrypted).catch((err) => { + // Log and display an error message if parsing fails + console.log( + 'An error occurred in parsing the user app data content from blossom server', + err + ) + toast.error( + 'An error occurred in parsing the user app data content from blossom server' + ) + return null + }) + + // Return the parsed content + return parsedContent +} + +/** + * Function to subscribe to sigits notifications for a specified public key. + * @param pubkey - The public key to subscribe to. + * @returns A promise that resolves when the subscription is successful. + */ +export const subscribeForSigits = async (pubkey: string) => { + // Instantiate the MetadataController to retrieve relay list metadata + const metadataController = new MetadataController() + const relaySet = await metadataController + .findRelayListMetadata(pubkey) + .catch((err) => { + // Log an error if retrieving relay list metadata fails + console.log( + `An error occurred while finding relay list metadata for ${hexToNpub(pubkey)}`, + err + ) + return null + }) + + // Return if metadata retrieval failed + if (!relaySet) return + + // Ensure relay list is not empty + if (relaySet.read.length === 0) return + + // Define the filter for the subscription + const filter: Filter = { + kinds: [1059], + '#p': [pubkey] + } + + // Instantiate a new SimplePool for the subscription + const pool = new SimplePool() + + // Subscribe to the specified relays with the defined filter + return pool.subscribeMany(relaySet.read, [filter], { + // Define a callback function to handle received events + onevent: (event) => { + processReceivedEvent(event) // Process the received event + } + }) +} + +const processReceivedEvent = async (event: Event, difficulty: number = 5) => { + const processedEvents = (store.getState().userAppData as UserAppData) + .processedGiftWraps + if (processedEvents.includes(event.id)) return + + store.dispatch(updateProcessedGiftWraps([...processedEvents, event.id])) + + // validate PoW + // Count the number of leading zero bits in the hash + const leadingZeroes = countLeadingZeroes(event.id) + if (leadingZeroes < difficulty) return + + // decrypt the content of gift wrap event + const nostrController = NostrController.getInstance() + const decrypted = await nostrController.nip44Decrypt( + event.pubkey, + event.content + ) + + const internalUnsignedEvent = await parseJson(decrypted).catch( + (err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err + ) + return null + } + ) + + if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return + + const meta = await parseJson(internalUnsignedEvent.content).catch( + (err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err + ) + return null + } + ) + + if (!meta) return + + updateUsersAppData(meta) +} + +/** + * Function to send a notification to a specified receiver. + * @param receiver - The recipient's public key. + * @param meta - Metadata associated with the notification. + */ +export const sendNotification = async (receiver: string, meta: Meta) => { + // Retrieve the user's public key from the state + const usersPubkey = (store.getState().auth as AuthState).usersPubkey! + + // Create an unsigned event object with the provided metadata + const unsignedEvent: UnsignedEvent = { + kind: 938, + pubkey: usersPubkey, + content: JSON.stringify(meta), + tags: [], + created_at: now() + } + + // Wrap the unsigned event with the receiver's information + const wrappedEvent = createWrap(unsignedEvent, receiver) + + // Instantiate the MetadataController to retrieve relay list metadata + const metadataController = new MetadataController() + const relaySet = await metadataController + .findRelayListMetadata(receiver) + .catch((err) => { + // Log an error if retrieving relay list metadata fails + console.log( + `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, + err + ) + return null + }) + + // Return if metadata retrieval failed + if (!relaySet) return + + // Ensure relay list is not empty + if (relaySet.read.length === 0) return + + console.log('Publishing notifications') + // Publish the notification event to the recipient's read relays + const nostrController = NostrController.getInstance() + + // Attempt to publish the event to the relays, with a timeout of 2 minutes + await Promise.race([ + nostrController.publishEvent(wrappedEvent, relaySet.read), + timeout(1000 * 30) + ]).catch((err) => { + // Log an error if publishing the notification event fails + console.log( + `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, + err + ) + throw err + }) +} diff --git a/src/utils/string.ts b/src/utils/string.ts index 09a9313..9ac0f05 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -93,3 +93,37 @@ export const parseJson = (content: string): Promise => { */ export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() + +export const formatTimestamp = (timestamp: number) => { + const date = new Date(timestamp) + + // Extract date parts + const day = date.getDate().toString().padStart(2, '0') + const month = date.toLocaleString('default', { month: 'short' }) + const year = date.getFullYear() + + // Format time parts + const hours = date.getHours() + const minutes = date.getMinutes().toString().padStart(2, '0') + const ampm = hours >= 12 ? 'PM' : 'AM' + const formattedHours = (hours % 12 || 12).toString().padStart(2, '0') + + // Combine parts into the desired format + return `${day} ${month} ${year} ${formattedHours}:${minutes} ${ampm}` +} + +/** + * Removes the leading slash from a given string if it exists. + * + * @param str - The string from which to remove the leading slash. + * @returns The string without the leading slash. + */ +export const removeLeadingSlash = (str: string): string => { + // Check if the string starts with a leading slash + if (str.startsWith('/')) { + // If it does, return the string without the leading slash + return str.slice(1) + } + // If it doesn't, return the original string + return str +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index fc5ade3..929af78 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -49,3 +49,18 @@ export const isOnline = async () => { return false // If an error occurs, return false } } + +/** + * Creates a promise that rejects with a timeout error after a specified duration. + * @param ms The duration in milliseconds after which the promise should reject. Defaults to 60000 milliseconds (1 minute). + * @returns A promise that rejects with an Error('Timeout') after the specified duration. + */ +export const timeout = (ms: number = 60000) => { + return new Promise((_, reject) => { + // Set a timeout using setTimeout + setTimeout(() => { + // Reject the promise with an Error indicating a timeout + reject(new Error('Timeout')) + }, ms) // Timeout duration in milliseconds + }) +}