diff --git a/package-lock.json b/package-lock.json index a03e759..2b59ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,8 +31,9 @@ "idb": "8.0.0", "jszip": "3.10.1", "lodash": "4.17.21", + "material-ui-popup-state": "^5.3.1", "mui-file-input": "4.0.4", - "nostr-login": "^1.6.6", + "nostr-login": "1.6.14", "nostr-tools": "2.7.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.4.168", @@ -3330,6 +3331,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3580,10 +3587,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5976,6 +5984,26 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/material-ui-popup-state": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/material-ui-popup-state/-/material-ui-popup-state-5.3.1.tgz", + "integrity": "sha512-mmx1DsQwF/2cmcpHvS/QkUwOQG2oAM+cDEQU0DaZVYnvwKyTB3AFgu8l1/E+LQFausmzpSJoljwQSZXkNvt7eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.6", + "@types/prop-types": "^15.7.3", + "@types/react": "^18.0.26", + "classnames": "^2.2.6", + "prop-types": "^15.7.2" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@mui/material": "^5.0.0 || ^6.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -6227,9 +6255,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -6237,6 +6265,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6415,9 +6444,9 @@ } }, "node_modules/nostr-login": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.6.tgz", - "integrity": "sha512-XOpB9nG3Qgt7iea7gA1zn4TaTfUKCKGdCHKwErqLPtMk/q1Rhkzj5cq/66iU0WqC6mSiwENfTy1p4qaM7HzMtg==", + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.14.tgz", + "integrity": "sha512-pId1G79kjRW1B9qy6OrA8Not23JSfgmS2VegcKf7Qm9VMC7wYGXg1Ry3FMEAB8p11WoboQ8oJi2TqUGiOf61OQ==", "license": "MIT", "dependencies": { "@nostr-dev-kit/ndk": "^2.3.1", diff --git a/package.json b/package.json index ab49da0..1c4b0aa 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,9 @@ "idb": "8.0.0", "jszip": "3.10.1", "lodash": "4.17.21", + "material-ui-popup-state": "^5.3.1", "mui-file-input": "4.0.4", - "nostr-login": "^1.6.6", + "nostr-login": "1.6.14", "nostr-tools": "2.7.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.4.168", diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index a4f653e..e7f5d95 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -125,7 +125,7 @@ export const AppBar = () => { src="/logo.svg" alt="Logo" onClick={() => { - if (window.location.pathname === '/') { + if (['', '#/'].includes(window.location.hash)) { location.reload() } else { navigate('/') diff --git a/src/components/FileList/index.tsx b/src/components/FileList/index.tsx index a47ace7..a073ca4 100644 --- a/src/components/FileList/index.tsx +++ b/src/components/FileList/index.tsx @@ -1,23 +1,25 @@ import { CurrentUserFile } from '../../types/file.ts' import styles from './style.module.scss' -import { Button } from '@mui/material' +import { Button, Menu, MenuItem } from '@mui/material' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCheck } from '@fortawesome/free-solid-svg-icons' +import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state' +import React from 'react' interface FileListProps { files: CurrentUserFile[] currentFile: CurrentUserFile setCurrentFile: (file: CurrentUserFile) => void - handleDownload: () => void - downloadLabel?: string + handleExport: () => void + handleEncryptedExport?: () => void } const FileList = ({ files, currentFile, setCurrentFile, - handleDownload, - downloadLabel + handleExport, + handleEncryptedExport }: FileListProps) => { const isActive = (file: CurrentUserFile) => file.id === currentFile.id return ( @@ -42,9 +44,35 @@ const FileList = ({ ))} - + + + {(popupState) => ( + + + + { + popupState.close + handleExport() + }} + > + Export Files + + { + popupState.close + typeof handleEncryptedExport === 'function' && + handleEncryptedExport() + }} + > + Export Encrypted Files + + + + )} + ) } diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 249b8c4..c7459e3 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -69,7 +69,7 @@ export const Footer = () => component={Link} to={'/'} onClick={(event) => { - if (window.location.pathname === '/') { + if (['', '#/'].includes(window.location.hash)) { event.preventDefault() window.scrollTo(0, 0) } diff --git a/src/components/MarkFormField/index.tsx b/src/components/MarkFormField/index.tsx index 18bbf31..99b9b7f 100644 --- a/src/components/MarkFormField/index.tsx +++ b/src/components/MarkFormField/index.tsx @@ -1,5 +1,4 @@ import { CurrentUserMark } from '../../types/mark.ts' -import styles from './style.module.scss' import { findNextIncompleteCurrentUserMark, getToolboxLabelByMarkType, @@ -8,12 +7,16 @@ import { } from '../../utils' import React, { useState } from 'react' import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCheck } from '@fortawesome/free-solid-svg-icons' +import { Button } from '@mui/material' +import styles from './style.module.scss' interface MarkFormFieldProps { currentUserMarks: CurrentUserMark[] handleCurrentUserMarkChange: (mark: CurrentUserMark) => void handleSelectedMarkValueChange: (value: string) => void - handleSubmit: (event: React.FormEvent) => void + handleSubmit: (event: React.MouseEvent) => void selectedMark: CurrentUserMark selectedMarkValue: string } @@ -30,11 +33,13 @@ const MarkFormField = ({ handleCurrentUserMarkChange }: MarkFormFieldProps) => { const [displayActions, setDisplayActions] = useState(true) + const [complete, setComplete] = useState(false) + const isReadyToSign = () => isCurrentUserMarksComplete(currentUserMarks) || isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue) const isCurrent = (currentMark: CurrentUserMark) => - currentMark.id === selectedMark.id + currentMark.id === selectedMark.id && !complete const isDone = (currentMark: CurrentUserMark) => isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted const findNext = () => { @@ -46,13 +51,36 @@ const MarkFormField = ({ const handleFormSubmit = (event: React.FormEvent) => { event.preventDefault() console.log('handle form submit runs...') - return isReadyToSign() - ? handleSubmit(event) - : handleCurrentUserMarkChange(findNext()!) + + // Without this line, we lose mark values when switching + handleCurrentUserMarkChange(selectedMark) + + if (!complete) { + isReadyToSign() + ? setComplete(true) + : handleCurrentUserMarkChange(findNext()!) + } } + const toggleActions = () => setDisplayActions(!displayActions) const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type) + const handleCurrentUserMarkClick = (mark: CurrentUserMark) => { + setComplete(false) + handleCurrentUserMarkChange(mark) + } + + const handleSelectCompleteMark = () => { + handleCurrentUserMarkChange(selectedMark) + setComplete(true) + } + + const handleSignAndComplete = ( + event: React.MouseEvent + ) => { + handleSubmit(event) + } + return (
@@ -78,33 +106,55 @@ const MarkFormField = ({
-

Add {markLabel}

+ {!complete && ( +

Add {markLabel}

+ )} + {complete &&

Finish

}
-
handleFormSubmit(e)}> - + {!complete && ( + handleFormSubmit(e)}> + +
+ +
+ + )} + + {complete && (
- +
- + )} +
{currentUserMarks.map((mark, index) => { return (
@@ -114,6 +164,22 @@ const MarkFormField = ({
) })} +
+ + {complete && ( +
+ )} +
diff --git a/src/components/MarkFormField/style.module.scss b/src/components/MarkFormField/style.module.scss index 686595f..e7a3e3c 100644 --- a/src/components/MarkFormField/style.module.scss +++ b/src/components/MarkFormField/style.module.scss @@ -70,6 +70,11 @@ margin-top: 10px; } + .completeButton { + font-size: 18px; + padding: 10px 20px; + } + .paginationButton { font-size: 12px; padding: 5px 10px; @@ -78,7 +83,8 @@ color: rgba(0, 0, 0, 0.5); } - .paginationButton:hover { + .paginationButton:hover, + .paginationButton:focus { background: #447592; color: rgba(255, 255, 255, 0.5); } @@ -216,3 +222,7 @@ flex-direction: column; grid-gap: 5px; } + +.finishPage { + padding: 1px 0; +} diff --git a/src/components/PDFView/PdfMarking.tsx b/src/components/PDFView/PdfMarking.tsx index 222c393..f0d4f60 100644 --- a/src/components/PDFView/PdfMarking.tsx +++ b/src/components/PDFView/PdfMarking.tsx @@ -24,11 +24,12 @@ import { interface PdfMarkingProps { currentUserMarks: CurrentUserMark[] files: CurrentUserFile[] - handleDownload: () => void + handleExport: () => void + handleEncryptedExport: () => void + handleSign: () => void meta: Meta | null otherUserMarks: Mark[] setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void - setIsMarksCompleted: (isMarksCompleted: boolean) => void setUpdatedMarks: (markToUpdate: Mark) => void } @@ -42,10 +43,11 @@ const PdfMarking = (props: PdfMarkingProps) => { const { files, currentUserMarks, - setIsMarksCompleted, setCurrentUserMarks, setUpdatedMarks, - handleDownload, + handleExport, + handleEncryptedExport, + handleSign, meta, otherUserMarks } = props @@ -86,11 +88,18 @@ const PdfMarking = (props: PdfMarkingProps) => { updatedSelectedMark ) setCurrentUserMarks(updatedCurrentUserMarks) - setSelectedMarkValue(mark.currentValue ?? EMPTY) - setSelectedMark(mark) + + // If clicking on the same mark, don't update the value, otherwise do update + if (mark.id !== selectedMark.id) { + setSelectedMarkValue(mark.currentValue ?? EMPTY) + setSelectedMark(mark) + } } - const handleSubmit = (event: React.FormEvent) => { + /** + * Sign and Complete + */ + const handleSubmit = (event: React.MouseEvent) => { event.preventDefault() if (!selectedMarkValue || !selectedMark) return @@ -106,8 +115,8 @@ const PdfMarking = (props: PdfMarkingProps) => { ) setCurrentUserMarks(updatedCurrentUserMarks) setSelectedMark(null) - setIsMarksCompleted(true) setUpdatedMarks(updatedMark.mark) + handleSign() } // const updateCurrentUserMarkValues = () => { @@ -132,7 +141,8 @@ const PdfMarking = (props: PdfMarkingProps) => { files={files} currentFile={currentFile} setCurrentFile={setCurrentFile} - handleDownload={handleDownload} + handleExport={handleExport} + handleEncryptedExport={handleEncryptedExport} /> )}
diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 85841f2..5c1159e 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -45,7 +45,7 @@ export interface FlatMeta isValid: boolean // Decryption - encryptionKey: string | null + encryptionKey: string | undefined // Parsed Document Signatures parsedSignatureEvents: { @@ -101,7 +101,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { [signer: `npub1${string}`]: SignStatus }>({}) - const [encryptionKey, setEncryptionKey] = useState(null) + const [encryptionKey, setEncryptionKey] = useState() useEffect(() => { if (!meta) return @@ -143,7 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setMarkConfig(markConfig) setZipUrl(zipUrl) - let encryptionKey: string | null = null + let encryptionKey: string | undefined if (meta.keys) { const { sender, keys } = meta.keys // Retrieve the user's public key from the state @@ -161,7 +161,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { 'An error occurred in decrypting encryption key', err ) - return null + return undefined }) encryptionKey = decrypted diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 19ac4d9..3e6be26 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -134,7 +134,15 @@ export const MainLayout = () => { initNostrLogin({ methods: ['connect', 'extension', 'local'], noBanner: true, - onAuth: handleNostrAuth + onAuth: handleNostrAuth, + outboxRelays: [ + 'wss://purplepag.es', + 'wss://relay.nos.social', + 'wss://user.kindpag.es', + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.sigit.io' + ] }).catch((error) => { console.error('Failed to initialize Nostr-Login', error) }) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 212f7bf..82f4d0c 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -31,6 +31,7 @@ import { KeyboardCode, Meta, ProfileMetadata, + SigitNotification, SignedEvent, User, UserRole @@ -52,7 +53,9 @@ import { updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + DEFAULT_LOOK_UP_RELAY_LIST, + uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -148,6 +151,17 @@ export const CreatePage = () => { [setUserInput] ) + const handleSearchUserNip05 = async ( + nip05: string + ): Promise => { + const { pubkey } = await queryNip05(nip05).catch((err) => { + console.error(err) + return { pubkey: null } + }) + + return pubkey + } + const handleSearchUsers = async (searchValue?: string) => { const searchString = searchValue || userSearchInput || undefined @@ -159,6 +173,14 @@ export const CreatePage = () => { const metadataController = MetadataController.getInstance() const relaySet = await metadataController.findRelayListMetadata(usersPubkey) + + DEFAULT_LOOK_UP_RELAY_LIST.forEach((relay) => { + if (!relaySet.write.includes(relay)) relaySet.write.push(relay) + if (!relaySet.read.includes(relay)) relaySet.read.push(relay) + }) + + const uniqueReadRelaySet = [...new Set(relaySet.read)] + const searchTerm = searchString.trim() relayController @@ -167,7 +189,7 @@ export const CreatePage = () => { kinds: [0], search: searchTerm }, - [...relaySet.write] + uniqueReadRelaySet ) .then((events) => { console.log('events', events) @@ -224,7 +246,9 @@ export const CreatePage = () => { }) }, [foundUsers]) - const handleInputKeyDown = (event: React.KeyboardEvent) => { + const handleInputKeyDown = async ( + event: React.KeyboardEvent + ) => { if ( event.code === KeyboardCode.Enter || event.code === KeyboardCode.NumpadEnter @@ -238,7 +262,23 @@ export const CreatePage = () => { } else { // Otherwize if search already provided some results, user must manually click the search button if (!foundUsers.length) { - handleSearchUsers() + // If it's NIP05 (includes @ or is a valid domain) send request to .well-known + const domainRegex = /^[a-zA-Z0-9@.-]+\.[a-zA-Z]{2,}$/ + if (domainRegex.test(userSearchInput)) { + setSearchUsersLoading(true) + + const pubkey = await handleSearchUserNip05(userSearchInput) + + setSearchUsersLoading(false) + + if (pubkey) { + setUserInput(userSearchInput) + } else { + toast.error(`No user found with the NIP05: ${userSearchInput}`) + } + } else { + handleSearchUsers() + } } } } @@ -782,7 +822,7 @@ export const CreatePage = () => { } // Send notifications to signers and viewers - const sendNotifications = (meta: Meta) => { + const sendNotifications = (notification: SigitNotification) => { // no need to send notification to self so remove it from the list const receivers = ( signers.length > 0 @@ -790,7 +830,7 @@ export const CreatePage = () => { : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) - return receivers.map((receiver) => sendNotification(receiver, meta)) + return receivers.map((receiver) => sendNotification(receiver, notification)) } const extractNostrId = (stringifiedEvent: string): string => { @@ -865,11 +905,17 @@ export const CreatePage = () => { } setLoadingSpinnerDesc('Updating user app data') + const event = await updateUsersAppData(meta) if (!event) return + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + setLoadingSpinnerDesc('Sending notifications to counterparties') - const promises = sendNotifications(meta) + const promises = sendNotifications({ + metaUrl, + keys: meta.keys + }) await Promise.all(promises) .then(() => { @@ -959,19 +1005,6 @@ export const CreatePage = () => { } else { disarmAddOnEnter() } - } else if (value.includes('@')) { - // Seems like it's nip05 format - const { pubkey } = await queryNip05(value).catch((err) => { - console.error(err) - return { pubkey: null } - }) - - if (pubkey) { - // Arm the manual user npub add after enter is hit, we don't want to trigger search - setPastedUserNpubOrNip05(hexToNpub(pubkey)) - } else { - disarmAddOnEnter() - } } else { // Disarm the add user on enter hit, and trigger search after 1 second disarmAddOnEnter() @@ -1146,7 +1179,9 @@ export const CreatePage = () => { ) : ( - - )} - - )} - - {submittedBy && Object.entries(files).length > 0 && meta && ( - <> - - - {signedStatus === SignedStatus.Fully_Signed && ( - - - - )} - - {signedStatus === SignedStatus.User_Is_Next_Signer && ( - - - - )} - - {isSignerOrCreator && ( - - - - )} - - )} - - + ) } diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 515a257..2ea8164 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -23,7 +23,13 @@ import { getCurrentUserFiles, updateUsersAppData, npubToHex, - sendNotification + sendNotification, + generateEncryptionKey, + encryptArrayBuffer, + generateKeysFile, + ARRAY_BUFFER, + DEFLATE, + uploadMetaToFileStorage } from '../../utils' import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' @@ -351,6 +357,11 @@ export const VerifyPage = () => { const updatedEvent = await updateUsersAppData(updatedMeta) if (!updatedEvent) return + const metaUrl = await uploadMetaToFileStorage( + updatedMeta, + encryptionKey + ) + const userSet = new Set<`npub1${string}`>() signers.forEach((signer) => { if (signer !== usersPubkey) { @@ -364,7 +375,10 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, updatedMeta) + sendNotification(npubToHex(user)!, { + metaUrl, + keys: meta.keys! + }) ) await Promise.all(promises) @@ -541,8 +555,114 @@ export const VerifyPage = () => { setIsLoading(false) } - const handleMarkedExport = async () => { - if (Object.entries(files).length === 0 || !meta || !usersPubkey) return + // Handle errors during zip file generation + const handleZipError = (err: unknown) => { + console.log('Error in zip:>> ', err) + setIsLoading(false) + if (err instanceof Error) { + toast.error(err.message || 'Error occurred in generating zip file') + } + return null + } + + // 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 + } + + // create final zip file + const createFinalZipFile = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ): Promise => { + // Get the current timestamp in seconds + const blob = new Blob([encryptedArrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed.sigit`, { + type: 'application/sigit' + }) + + const isLastSigner = checkIsLastSigner(signers) + + 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)!) + } + + const keysFileContent = await generateKeysFile( + Array.from(userSet), + 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 null + + return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, { + type: 'application/zip' + }) + } + + const handleExport = async () => { + const arrayBuffer = await prepareZipExport() + if (!arrayBuffer) return + + const blob = new Blob([arrayBuffer]) + saveAs(blob, `exported-${unixNow()}.sigit.zip`) + + setIsLoading(false) + } + + const handleEncryptedExport = async () => { + const arrayBuffer = await prepareZipExport() + if (!arrayBuffer) return + + const key = await generateEncryptionKey() + + setLoadingSpinnerDesc('Encrypting zip file') + const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) + + const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) + + if (!finalZipFile) return + saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`) + + setIsLoading(false) + } + + const prepareZipExport = async (): Promise => { + if (Object.entries(files).length === 0 || !meta || !usersPubkey) + return Promise.resolve(null) const usersNpub = hexToNpub(usersPubkey) if ( @@ -550,14 +670,14 @@ export const VerifyPage = () => { !viewers.includes(usersNpub) && submittedBy !== usersNpub ) { - return + return Promise.resolve(null) } setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') const prevSig = getLastSignersSig(meta, signers) - if (!prevSig) return + if (!prevSig) return Promise.resolve(null) const signedEvent = await signEventForMetaFile( JSON.stringify({ prevSig }), @@ -565,7 +685,7 @@ export const VerifyPage = () => { setIsLoading ) - if (!signedEvent) return + if (!signedEvent) return Promise.resolve(null) const exportSignature = JSON.stringify(signedEvent, null, 2) const updatedMeta = { ...meta, exportSignature } @@ -576,8 +696,8 @@ export const VerifyPage = () => { const arrayBuffer = await zip .generateAsync({ - type: 'arraybuffer', - compression: 'DEFLATE', + type: ARRAY_BUFFER, + compression: DEFLATE, compressionOptions: { level: 6 } @@ -589,12 +709,9 @@ export const VerifyPage = () => { return null }) - if (!arrayBuffer) return + if (!arrayBuffer) return Promise.resolve(null) - const blob = new Blob([arrayBuffer]) - saveAs(blob, `exported-${unixNow()}.sigit.zip`) - - setIsLoading(false) + return Promise.resolve(arrayBuffer) } return ( @@ -640,8 +757,8 @@ export const VerifyPage = () => { )} currentFile={currentFile} setCurrentFile={setCurrentFile} - handleDownload={handleMarkedExport} - downloadLabel="Download Sigit" + handleExport={handleExport} + handleEncryptedExport={handleEncryptedExport} /> ) } diff --git a/src/types/core.ts b/src/types/core.ts index 38cfb94..3b2af54 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -84,3 +84,12 @@ export interface UserAppData { export interface DocSignatureEvent extends Event { parsedContent?: SignedEventContent } + +export interface SigitNotification { + metaUrl: string + keys?: { sender: string; keys: { [user: `npub1${string}`]: string } } +} + +export function isSigitNotification(obj: unknown): obj is SigitNotification { + return typeof (obj as SigitNotification).metaUrl === 'string' +} diff --git a/src/types/errors/MetaStorageError.ts b/src/types/errors/MetaStorageError.ts new file mode 100644 index 0000000..a5bc2cd --- /dev/null +++ b/src/types/errors/MetaStorageError.ts @@ -0,0 +1,26 @@ +import { Jsonable } from '.' + +export enum MetaStorageErrorType { + 'ENCRYPTION_KEY_REQUIRED' = 'Encryption key is required.', + 'HASHING_FAILED' = "Can't get encrypted file hash.", + 'FETCH_FAILED' = 'Fetching meta.json requires an encryption key.', + 'HASH_VERIFICATION_FAILED' = 'Unable to verify meta.json.', + 'DECRYPTION_FAILED' = 'Error decryping meta.json.', + 'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.' +} + +export class MetaStorageError extends Error { + public readonly context?: Jsonable + + constructor( + message: MetaStorageErrorType, + options: { cause?: Error; context?: Jsonable } = {} + ) { + const { cause, context } = options + + super(message, { cause }) + this.name = this.constructor.name + + this.context = context + } +} diff --git a/src/utils/const.ts b/src/utils/const.ts index 522f242..2b8e822 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -120,5 +120,5 @@ export const SIGNATURE_PAD_OPTIONS = { export const SIGNATURE_PAD_SIZE = { width: 300, - height: 300 + height: 150 } diff --git a/src/utils/meta.ts b/src/utils/meta.ts index c915f66..8052abf 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -1,5 +1,12 @@ import { CreateSignatureEventContent, Meta } from '../types' -import { fromUnixTimestamp, parseJson } from '.' +import { + decryptArrayBuffer, + encryptArrayBuffer, + fromUnixTimestamp, + getHash, + parseJson, + uploadToFileStorage +} from '.' import { Event, verifyEvent } from 'nostr-tools' import { toast } from 'react-toastify' import { extractFileExtensions } from './file' @@ -8,6 +15,11 @@ import { MetaParseError, MetaParseErrorType } from '../types/errors/MetaParseError' +import axios from 'axios' +import { + MetaStorageError, + MetaStorageErrorType +} from '../types/errors/MetaStorageError' export enum SignStatus { Signed = 'Signed', @@ -126,3 +138,76 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } } } + +export const uploadMetaToFileStorage = async ( + meta: Meta, + encryptionKey: string | undefined +) => { + // Value is the stringified meta object + const value = JSON.stringify(meta) + const encoder = new TextEncoder() + + // Encode it to the arrayBuffer + const uint8Array = encoder.encode(value) + + if (!encryptionKey) { + throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED) + } + + // Encrypt the file contents with the same encryption key from the create signature + const encryptedArrayBuffer = await encryptArrayBuffer( + uint8Array, + encryptionKey + ) + + const hash = await getHash(encryptedArrayBuffer) + if (!hash) { + throw new MetaStorageError(MetaStorageErrorType.HASHING_FAILED) + } + + // Create the encrypted json file from array buffer and hash + const file = new File([encryptedArrayBuffer], `${hash}.json`) + const url = await uploadToFileStorage(file) + + return url +} + +export const fetchMetaFromFileStorage = async ( + url: string, + encryptionKey: string | undefined +) => { + if (!encryptionKey) { + throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED) + } + + const encryptedArrayBuffer = await axios.get(url, { + responseType: 'arraybuffer' + }) + + // Verify hash + const parts = url.split('/') + const urlHash = parts[parts.length - 1] + const hash = await getHash(encryptedArrayBuffer.data) + if (hash !== urlHash) { + throw new MetaStorageError(MetaStorageErrorType.HASH_VERIFICATION_FAILED) + } + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer.data, + encryptionKey + ).catch((err) => { + throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, { + cause: err + }) + }) + + if (arrayBuffer) { + // Decode meta.json and parse + const decoder = new TextDecoder() + const json = decoder.decode(arrayBuffer) + const meta = await parseJson(json) + return meta + } + + throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR) +} diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index ac7ff72..b98dabb 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -29,12 +29,20 @@ import { } from '../store/actions' import { Keys } from '../store/auth/types' import store from '../store/store' -import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types' +import { + isSigitNotification, + Meta, + ProfileMetadata, + SigitNotification, + SignedEvent, + UserAppData +} from '../types' import { getDefaultRelayMap } from './relays' import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' import { getHash } from './hash' import { SIGIT_BLOSSOM } from './const.ts' +import { fetchMetaFromFileStorage } from './meta.ts' /** * Generates a `d` tag for userAppData @@ -908,17 +916,48 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { 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 - } - ) + const parsedContent = await parseJson( + internalUnsignedEvent.content + ).catch((err) => { + console.log('An error occurred in parsing the internal unsigned event', err) + return null + }) - if (!meta) return + if (!parsedContent) return + let meta: Meta + if (isSigitNotification(parsedContent)) { + const notification = parsedContent + let encryptionKey: string | undefined + if (!notification.keys) return + + const { sender, keys } = notification.keys + + // Retrieve the user's public key from the state + const usersPubkey = store.getState().auth.usersPubkey! + const usersNpub = hexToNpub(usersPubkey) + + // 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) => { + console.log('An error occurred in decrypting encryption key', err) + return undefined + }) + + encryptionKey = decrypted + } + try { + meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + } catch (error) { + console.error(`An error occured fetching meta file from storage`, error) + return + } + } else { + meta = parsedContent + } await updateUsersAppData(meta) } @@ -926,9 +965,12 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { /** * Function to send a notification to a specified receiver. * @param receiver - The recipient's public key. - * @param meta - Metadata associated with the notification. + * @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt. */ -export const sendNotification = async (receiver: string, meta: Meta) => { +export const sendNotification = async ( + receiver: string, + notification: SigitNotification +) => { // Retrieve the user's public key from the state const usersPubkey = store.getState().auth.usersPubkey! @@ -936,7 +978,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => { const unsignedEvent: UnsignedEvent = { kind: 938, pubkey: usersPubkey, - content: JSON.stringify(meta), + content: JSON.stringify(notification), tags: [], created_at: unixNow() } diff --git a/src/utils/relays.ts b/src/utils/relays.ts index bfef7aa..7a0ad56 100644 --- a/src/utils/relays.ts +++ b/src/utils/relays.ts @@ -80,8 +80,8 @@ const getUserRelaySet = (tags: string[][]): RelaySet => { } const getDefaultRelaySet = (): RelaySet => ({ - read: [SIGIT_RELAY], - write: [SIGIT_RELAY] + read: DEFAULT_LOOK_UP_RELAY_LIST, + write: DEFAULT_LOOK_UP_RELAY_LIST }) const getDefaultRelayMap = (): RelayMap => ({