diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index 613ec44..88f8a75 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -46,7 +46,6 @@ export class MetadataController extends EventEmitter { const pool = new SimplePool() - // todo: use nostrController to get event // Try to get the metadata event from a special relay (wss://purplepag.es) const metadataEvent = await pool .get([this.specialMetadataRelay], eventFilter) @@ -62,14 +61,17 @@ 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 } - // todo use nostr controller to find event from connected relays // If no valid metadata event is found from the special relay, get the most popular relays const mostPopularRelays = await this.nostrController.getMostPopularRelays() @@ -125,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) } diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 2c13cf2..274c849 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,30 +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, 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() @@ -84,11 +94,47 @@ export const MainLayout = () => { metadataController.findMetadata(usersPubkey).then((metadataEvent) => { if (metadataEvent) handleMetadataEvent(metadataEvent) }) + + setLoadingSpinnerDesc(`Fetching user's app data`) + getUsersAppData() + .then((appData) => { + if (appData) { + dispatch(updateUserAppData(appData)) + } + }) + .finally(() => setIsLoading(false)) + } 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) { + /** + * Sigit notifications are wrapped using nip 59 seal and gift wrap scheme. + * According to nip59 created_at for seal and gift wrap should be tweaked to thwart time-analysis attacks. + * For the above purpose created_at is set to random time upto 2 days in past + */ + 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,13 +147,11 @@ export const MainLayout = () => { if (pubkey) { dispatch(setUserRobotImage(getRoboHashPicture(pubkey))) - - subscribeForSigits(pubkey) } } }, [authState]) - if (isLoading) return + if (isLoading) return return ( <> diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 962afd6..7c49dd4 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -23,19 +23,23 @@ import saveAs from 'file-saver' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' import { Event, kinds } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' +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' -import { v4 as uuidV4 } from 'uuid' 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, generateEncryptionKey, @@ -54,6 +58,7 @@ import { uploadToFileStorage } from '../../utils' import styles from './style.module.scss' +import { appPrivateRoutes } from '../../routes' export const CreatePage = () => { const navigate = useNavigate() @@ -83,10 +88,6 @@ export const CreatePage = () => { setAuthUrl(url) }) - const uuid = useMemo(() => { - return uuidV4() - }, []) - useEffect(() => { if (uploadedFile) { setSelectedFiles([uploadedFile]) @@ -295,56 +296,6 @@ export const CreatePage = () => { return fileHashes } - // initialize a zip file with the selected files and generate creator's signature - const initZipFileAndCreatorSignature = async ( - encryptionKey: string, - fileHashes: { - [key: string]: string - } - ): Promise<{ zip: JSZip; createSignature: string } | null> => { - const zip = new JSZip() - - selectedFiles.forEach((file) => { - zip.file(`files/${file.name}`, file) - }) - - // 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) - - 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, - keys - }), - nostrController, - setIsLoading - ) - - if (!createSignature) return null - - try { - return { - zip, - createSignature: JSON.stringify(createSignature, null, 2) - } - } catch (error) { - return null - } - } - // Handle errors during zip file generation const handleZipError = (err: any) => { console.log('Error in zip:>> ', err) @@ -377,7 +328,7 @@ 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 @@ -423,28 +374,6 @@ export const CreatePage = () => { return finalZipFile } - const handleOnlineFlow = async ( - encryptedArrayBuffer: ArrayBuffer, - meta: Meta - ) => { - const unixNow = now() - 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 updatedEvent = await updateUsersAppData(fileUrl, meta) - if (!updatedEvent) return - - await sendDMs(fileUrl, meta) - - navigate(appPrivateRoutes.sign, { state: { sigit: { fileUrl, meta } } }) - } - // Handle errors during file upload const handleUploadError = (err: any) => { console.log('Error in upload:>> ', err) @@ -454,13 +383,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) .then((url) => { - toast.success('Sigit uploaded to file storage') + toast.success('files.zip uploaded to file storage') return url }) .catch(handleUploadError) @@ -468,24 +403,6 @@ export const CreatePage = () => { return fileUrl } - // Send DMs to signers and viewers with the file URL - const sendDMs = async (fileUrl: string, meta: Meta) => { - setLoadingSpinnerDesc('Sending DM to signers/viewers') - - const signers = users.filter((user) => user.role === UserRole.signer) - const viewers = users.filter((user) => user.role === UserRole.viewer) - - const receivers = - signers.length > 0 - ? [signers[0].pubkey] - : viewers.map((viewer) => viewer.pubkey) - - const promises = receivers.map((receiver) => - sendNotification(receiver, meta, fileUrl) - ) - await Promise.allSettled(promises) - } - // Manage offline scenarios for signing or viewing the file const handleOfflineFlow = async ( encryptedArrayBuffer: ArrayBuffer, @@ -501,49 +418,195 @@ 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') + + try { + const createSignature = await signEventForMetaFile( + JSON.stringify(content), + nostrController, + setIsLoading + ) + + if (!createSignature) return null + + return JSON.stringify(createSignature, null, 2) + } catch (error) { + return null + } + } + + // 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 encryptionKey = await generateEncryptionKey() - - const createZipResponse = await initZipFileAndCreatorSignature( - encryptionKey, - fileHashes - ) - if (!createZipResponse) return - - const { zip, createSignature } = createZipResponse - - // create content for meta file - const meta: Meta = { - uuid, - title, - modifiedAt: now(), - createSignature, - docSignatures: {} + if (!fileHashes) { + setIsLoading(false) + return } - setLoadingSpinnerDesc('Generating zip file') - - const arrayBuffer = await generateZipFile(zip) - if (!arrayBuffer) return - - setLoadingSpinnerDesc('Encrypting zip file') - const encryptedArrayBuffer = await encryptZipFile( - arrayBuffer, - encryptionKey - ) + setLoadingSpinnerDesc('Generating encryption key') + const encryptionKey = await generateEncryptionKey() if (await isOnline()) { - await handleOnlineFlow(encryptedArrayBuffer, meta) + 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 { - // todo: fix offline flow + const zip = new JSZip() + + selectedFiles.forEach((file) => { + zip.file(`files/${file.name}`, file) + }) + + setLoadingSpinnerDesc('Generating create signature') + const createSignature = await generateCreateSignature(fileHashes, '') + if (!createSignature) 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) } } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 30d39db..0f5c775 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,59 +1,38 @@ import { Add, 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 { toast } from 'react-toastify' +import { UserComponent } from '../../components/username' +import { MetadataController } from '../../controllers' +import { useAppSelector } from '../../hooks' import { appPrivateRoutes, appPublicRoutes } from '../../routes' -import styles from './style.module.scss' -import { MetadataController, NostrController } from '../../controllers' +import { CreateSignatureEventContent, Meta, ProfileMetadata } from '../../types' import { formatTimestamp, - getUsersAppData, hexToNpub, npubToHex, parseJson, shorten } from '../../utils' -import { LoadingSpinner } from '../../components/LoadingSpinner' -import { - CreateSignatureEventContent, - Meta, - ProfileMetadata, - Sigit -} from '../../types' -import { Event, kinds, verifyEvent } from 'nostr-tools' -import { UserComponent } from '../../components/username' +import styles from './style.module.scss' export const HomePage = () => { const navigate = useNavigate() const fileInputRef = useRef(null) - const [isLoading, setIsLoading] = useState(true) - const [loadingSpinnerDesc] = useState(`Finding user's app data`) - const [authUrl, setAuthUrl] = useState() - const [sigits, setSigits] = useState([]) - + const [sigits, setSigits] = useState([]) const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( {} ) + const usersAppData = useAppSelector((state) => state.userAppData) useEffect(() => { - const nostrController = NostrController.getInstance() - // Set up event listener for authentication event - nostrController.on('nsecbunker-auth', (url) => { - setAuthUrl(url) - }) - - getUsersAppData() - .then((res) => { - if (res) { - setSigits(Object.values(res)) - } - }) - .finally(() => { - setIsLoading(false) - }) - }, []) + if (usersAppData) { + setSigits(Object.values(usersAppData.sigits)) + } + }, [usersAppData]) const handleUploadClick = () => { if (fileInputRef.current) { @@ -101,20 +80,8 @@ export const HomePage = () => { } } - if (authUrl) { - return ( -