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/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 ecc9cc9..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) { @@ -385,7 +570,7 @@ export class NostrController extends EventEmitter { * @param content - The encrypted content to decrypt. * @returns A promise that resolves to the decrypted content. */ - nip04Decrypt = async (sender: string, content: string) => { + nip04Decrypt = async (sender: string, content: string): Promise => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (loginMethod === LoginMethods.extension) { 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 84ad3bf..f90ee77 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -18,9 +18,14 @@ 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 { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' @@ -29,28 +34,32 @@ 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 { Event, kinds } from 'nostr-tools' export const CreatePage = () => { const navigate = useNavigate() @@ -62,7 +71,7 @@ export const CreatePage = () => { const [authUrl, setAuthUrl] = useState() - const [title, setTitle] = useState('') + const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [selectedFiles, setSelectedFiles] = useState([]) const [userInput, setUserInput] = useState('') @@ -75,6 +84,11 @@ 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]) @@ -283,75 +297,6 @@ export const CreatePage = () => { return fileHashes } - // Create a zip file with the selected files and sign the event - const createZipFile = async (fileHashes: { - [key: string]: string - }): Promise<{ zip: JSZip; createSignature: string } | null> => { - const zip = new JSZip() - - 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 null - - try { - return { - zip, - createSignature: JSON.stringify(createSignature, null, 2) - } - } catch (error) { - return null - } - } - - // Add metadata and file hashes to the zip file - const addMetaToZip = async ( - zip: JSZip, - createSignature: string - ): Promise => { - // create content for meta file - const meta: Meta = { - title, - createSignature, - docSignatures: {} - } - - try { - const stringifiedMeta = JSON.stringify(meta, null, 2) - zip.file('meta.json', stringifiedMeta) - - const metaHash = await getHash(stringifiedMeta) - if (!metaHash) return null - - const metaHashJson = { - [usersPubkey!]: metaHash - } - - zip.file('hashes.json', JSON.stringify(metaHashJson, null, 2)) - return metaHash - } catch (err) { - console.error(err) - toast.error('An error occurred in converting meta json to string') - return null - } - } - // Handle errors during zip file generation const handleZipError = (err: any) => { console.log('Error in zip:>> ', err) @@ -384,13 +329,13 @@ export const CreatePage = () => { return encryptArrayBuffer(arraybuffer, encryptionKey) } - // create final zip file + // create final zip file for offline mode const createFinalZipFile = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ): Promise => { // Get the current timestamp in seconds - const unixNow = Math.floor(Date.now() / 1000) + const unixNow = now() const blob = new Blob([encryptedArrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed.sigit`, { @@ -430,23 +375,6 @@ export const CreatePage = () => { return finalZipFile } - const handleOnlineFlow = async ( - encryptedArrayBuffer: ArrayBuffer, - encryptionKey: string - ) => { - 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-${unixNow}.sigit`, { - type: 'application/sigit' - }) - - const fileUrl = await uploadFile(file) - if (!fileUrl) return - - await sendDMs(fileUrl, encryptionKey) - } - // Handle errors during file upload const handleUploadError = (err: any) => { console.log('Error in upload:>> ', err) @@ -456,13 +384,19 @@ export const CreatePage = () => { } // Upload the file to the storage - const uploadFile = async (file: File): Promise => { - setIsLoading(true) - setLoadingSpinnerDesc('Uploading sigit to file 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, nostrController) + const fileUrl = await uploadToFileStorage(file) .then((url) => { - toast.success('Sigit uploaded to file storage') + toast.success('files.zip uploaded to file storage') return url }) .catch(handleUploadError) @@ -470,36 +404,6 @@ export const CreatePage = () => { return fileUrl } - // Send DMs to signers and viewers with the file URL - const sendDMs = async (fileUrl: string, encryptionKey: string) => { - setLoadingSpinnerDesc('Sending DM to signers/viewers') - - const signers = users.filter((user) => user.role === UserRole.signer) - const viewers = users.filter((user) => user.role === UserRole.viewer) - - if (signers.length > 0) { - await sendDM( - fileUrl, - encryptionKey, - signers[0].pubkey, - nostrController, - true, - setAuthUrl - ) - } else { - for (const viewer of viewers) { - await sendDM( - fileUrl, - encryptionKey, - viewer.pubkey, - nostrController, - false, - setAuthUrl - ) - } - } - } - // Manage offline scenarios for signing or viewing the file const handleOfflineFlow = async ( encryptedArrayBuffer: ArrayBuffer, @@ -515,43 +419,200 @@ export const CreatePage = () => { 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 hashes for files') - + setLoadingSpinnerDesc('Generating file hashes') const fileHashes = await generateFileHashes() - if (!fileHashes) return - - const createZipResponse = await createZipFile(fileHashes) - if (!createZipResponse) return - - const { zip, createSignature } = createZipResponse - - const metaHash = await addMetaToZip(zip, createSignature) - if (!metaHash) return - - setLoadingSpinnerDesc('Generating zip file') - - const arrayBuffer = await generateZipFile(zip) - if (!arrayBuffer) return - - const encryptionKey = await generateEncryptionKey() - - setLoadingSpinnerDesc('Encrypting zip file') - const encryptedArrayBuffer = await encryptZipFile( - arrayBuffer, - encryptionKey - ) - - if (await isOnline()) { - await handleOnlineFlow(encryptedArrayBuffer, encryptionKey) - } else { - await handleOfflineFlow(encryptedArrayBuffer, encryptionKey) + if (!fileHashes) { + setIsLoading(false) + return } - navigate(appPrivateRoutes.sign, { state: { arrayBuffer } }) + setLoadingSpinnerDesc('Generating encryption key') + const encryptionKey = await generateEncryptionKey() + + if (await isOnline()) { + setLoadingSpinnerDesc('generating files.zip') + const arrayBuffer = await generateFilesZip() + if (!arrayBuffer) { + setIsLoading(false) + return + } + + setLoadingSpinnerDesc('Encrypting files.zip') + const encryptedArrayBuffer = await encryptZipFile( + arrayBuffer, + encryptionKey + ) + + 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: { 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) + } } if (authUrl) { diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 181b67d..7f78987 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,21 +1,38 @@ -import { - Add, - CalendarMonth, - Description, - PersonOutline, - Upload -} from '@mui/icons-material' +import { CalendarMonth, Description, Upload } from '@mui/icons-material' import { Box, Button, Tooltip, Typography } from '@mui/material' -import { useNavigate } from 'react-router-dom' -import { appPrivateRoutes, appPublicRoutes } from '../../routes' -import styles from './style.module.scss' -import { useRef } from 'react' 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 { 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) { @@ -64,80 +81,184 @@ export const HomePage = () => { } return ( - - - - Sigits - - {/* This is for desktop view */} - - - - - - {/* This is for mobile view */} - - + - - - - + + {/* This is for mobile view */} + + + + + + + + {sigits.map((sigit, index) => ( + + ))} - - - - - - + ) } -const PlaceHolder = () => { +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 ( { md: 'row' } }} + onClick={handleNavigation} > { } }} > - + - Title + {title} - - - Sigit - - + {submittedBy && + (function () { + const profile = profiles[submittedBy] + return ( + + ) + })()} + - 07 Jun 10:23 AM + {createdAt} - - - Sent - - placeholder@sigit.io - - - - Awaiting - - placeholder@sigit.io - + {signers.map((signer) => { + const pubkey = npubToHex(signer)! + const profile = profiles[pubkey] + + return ( + + ) + })} ) } + +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 ( + + + {signStatus} + + + + ) +} diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index e722d70..62135f1 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -28,23 +28,40 @@ .item { display: flex; + gap: 10px; background-color: #efeae6; border-radius: 1rem; + cursor: pointer; .titleBox { display: flex; - align-items: flex-start; - justify-content: space-between; + flex: 4; + flex-direction: column; + align-items: center; + overflow-wrap: anywhere; + gap: 10px; padding: 10px; 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 b49d11e..5204ea7 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -7,7 +7,7 @@ import { MuiFileInput } from 'mui-file-input' import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' -import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' @@ -17,17 +17,19 @@ import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { decryptArrayBuffer, encryptArrayBuffer, + extractZipUrlAndEncryptionKey, generateEncryptionKey, generateKeysFile, getHash, hexToNpub, isOnline, + now, npubToHex, parseJson, readContentOfZipEntry, - sendDM, + sendNotification, signEventForMetaFile, - uploadToFileStorage + updateUsersAppData } from '../../utils' import { DisplayMeta } from './internal/displayMeta' import styles from './style.module.scss' @@ -40,16 +42,23 @@ enum SignedStatus { export const SignPage = () => { const navigate = useNavigate() const location = useLocation() - const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = - 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 [zip, setZip] = useState() + const [files, setFiles] = useState<{ [filename: string]: ArrayBuffer }>({}) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') @@ -80,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 @@ -160,43 +134,94 @@ 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(async (res) => { - const fileName = fileUrl.split('/').pop() - const file = new File([res.data], fileName!) + const isValidCreateSignature = verifyEvent(createSignatureEvent) - const encryptedArrayBuffer = await file.arrayBuffer() + if (!isValidCreateSignature) { + toast.error('Create signature is invalid') + setIsLoading(false) + 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 - }) - - 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}` + 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 }) + + 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) @@ -217,7 +242,63 @@ export const SignPage = () => { setIsLoading(false) setDisplayInput(true) } - }, [searchParams, decryptedArrayBuffer, uploadedZip]) + }, [decryptedArrayBuffer, uploadedZip, metaInNavState]) + + 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( @@ -325,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') @@ -352,53 +463,6 @@ 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) } @@ -414,23 +478,9 @@ 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 readHashesFile() - if (!hashesFileContent) return - - if (!hashesFileContent) { - setIsLoading(false) - return - } - - const hashes = await parseHashes(hashesFileContent) - if (!hashes) return - - setLoadingSpinnerDesc('Generating hashes for files') setLoadingSpinnerDesc('Signing nostr event') @@ -442,55 +492,14 @@ export const SignPage = () => { const updatedMeta = updateMetaSignatures(meta, signedEvent) - const stringifiedMeta = JSON.stringify(updatedMeta, null, 2) - zip.file('meta.json', stringifiedMeta) - - const metaHash = await getHash(stringifiedMeta) - if (!metaHash) return - - const updatedHashes = updateHashes(hashes, metaHash) - zip.file('hashes.json', JSON.stringify(updatedHashes, null, 2)) - - const arrayBuffer = await generateZipArrayBuffer(zip) - if (!arrayBuffer) return - - const key = await generateEncryptionKey() - - setLoadingSpinnerDesc('Encrypting zip file') - const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) - if (await isOnline()) { - await handleOnlineFlow(encryptedArrayBuffer, key) + await handleOnlineFlow(updatedMeta) } else { - handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false)) + setMeta(updatedMeta) + setIsLoading(false) } } - // Read the content of the hashes.json file - const readHashesFile = async (): Promise => { - return await readContentOfZipEntry(zip!, 'hashes.json', 'string').catch( - (err) => { - console.log('Error reading hashes.json file:', err) - setIsLoading(false) - return null - } - ) - } - - // Parse the JSON content of the hashes file - const parseHashes = async ( - hashesFileContent: string - ): Promise | null> => { - return await parseJson>(hashesFileContent).catch( - (err) => { - console.log('Error parsing hashes.json content:', err) - toast.error(err.message || 'Error parsing hashes.json content') - setIsLoading(false) - return null - } - ) - } - // Sign the event for the meta file const signEventForMeta = async (prevSig: string) => { return await signEventForMetaFile( @@ -507,40 +516,10 @@ export const SignPage = () => { ...metaCopy.docSignatures, [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) } + metaCopy.modifiedAt = now() return metaCopy } - // Update the hashes with the new meta hash - const updateHashes = ( - hashes: Record, - metaHash: string - ): Record => { - return { - ...hashes, - [usersPubkey!]: metaHash - } - } - - // Generate the zip array buffer - const generateZipArrayBuffer = async ( - zip: JSZip - ): Promise => { - return await zip - .generateAsync({ - type: 'arraybuffer', - compression: 'DEFLATE', - compressionOptions: { - level: 6 - } - }) - .catch((err) => { - console.log('Error generating zip file:', err) - setIsLoading(false) - toast.error(err.message || 'Error generating zip file') - return null - }) - } - // create final zip file const createFinalZipFile = async ( encryptedArrayBuffer: ArrayBuffer, @@ -616,59 +595,59 @@ export const SignPage = () => { return null } - // Handle the online flow: upload file and send DMs - const handleOnlineFlow = async ( - encryptedArrayBuffer: ArrayBuffer, - encryptionKey: string - ) => { - 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-${unixNow}.sigit`, { - type: 'application/sigit' - }) - - const fileUrl = await uploadFile(file) - if (!fileUrl) return - - const isLastSigner = checkIsLastSigner(signers) - - if (isLastSigner) { - await sendDMToAllUsers(fileUrl, encryptionKey) - } else { - await sendDMToNextSigner(fileUrl, encryptionKey) + // 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: encryptionKey - }) + const userSet = new Set<`npub1${string}`>() + if (submittedBy && submittedBy !== usersPubkey) { + userSet.add(hexToNpub(submittedBy)) + } - setIsLoading(false) - } - - // 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 file storage - const uploadFile = async (file: File): Promise => { - setIsLoading(true) - setLoadingSpinnerDesc('Uploading sigit file to file storage.') - - const fileUrl = await uploadToFileStorage(file, nostrController) - .then((url) => { - toast.success('Sigit uploaded to file storage') - return url + const usersNpub = hexToNpub(usersPubkey!) + const isLastSigner = checkIsLastSigner(signers) + if (isLastSigner) { + signers.forEach((signer) => { + if (signer !== usersNpub) { + userSet.add(signer) + } }) - .catch(handleUploadError) - return fileUrl + viewers.forEach((viewer) => { + userSet.add(viewer) + }) + } else { + 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 @@ -679,53 +658,8 @@ export const SignPage = () => { return signerIndex === lastSignerIndex } - // Send DM to all users (signers and viewers) - const sendDMToAllUsers = async (fileUrl: string, encryptionKey: string) => { - 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) { - await sendDM( - fileUrl, - encryptionKey, - npubToHex(user)!, - nostrController, - false, - setAuthUrl - ) - } - } - - // Send DM to the next signer - const sendDMToNextSigner = async (fileUrl: string, encryptionKey: string) => { - const usersNpub = hexToNpub(usersPubkey!) - const signerIndex = signers.indexOf(usersNpub) - const nextSigner = signers[signerIndex + 1] - await sendDM( - fileUrl, - encryptionKey, - npubToHex(nextSigner)!, - nostrController, - true, - setAuthUrl - ) - } - const handleExport = async () => { - if (!meta || !zip || !usersPubkey) return + if (Object.entries(files).length === 0 || !meta || !usersPubkey) return const usersNpub = hexToNpub(usersPubkey) if ( @@ -738,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( @@ -761,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', @@ -790,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({ @@ -925,11 +876,11 @@ export const SignPage = () => { )} - {submittedBy && zip && meta && ( + {submittedBy && Object.entries(files).length > 0 && meta && ( <> { )} - {/* todo: In offline mode export sigit is not visible after last signer has signed*/} {isSignerOrCreator && (