From 9a1d3d98bf866b97e9d9748cdad9e9159b4ef7d9 Mon Sep 17 00:00:00 2001 From: enes Date: Sat, 4 Jan 2025 19:28:30 +0100 Subject: [PATCH 01/17] feat: add minimal styling secondary button --- src/components/ButtonUnderline/index.tsx | 24 ++++++++++++++++++ .../ButtonUnderline/style.module.scss | 25 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/components/ButtonUnderline/index.tsx create mode 100644 src/components/ButtonUnderline/style.module.scss diff --git a/src/components/ButtonUnderline/index.tsx b/src/components/ButtonUnderline/index.tsx new file mode 100644 index 0000000..510b2a9 --- /dev/null +++ b/src/components/ButtonUnderline/index.tsx @@ -0,0 +1,24 @@ +import { PropsWithChildren } from 'react' +import styles from './style.module.scss' + +interface ButtonUnderlineProps { + onClick: () => void + disabled?: boolean | undefined +} + +export const ButtonUnderline = ({ + onClick, + disabled = false, + children +}: PropsWithChildren) => { + return ( + + ) +} diff --git a/src/components/ButtonUnderline/style.module.scss b/src/components/ButtonUnderline/style.module.scss new file mode 100644 index 0000000..a357362 --- /dev/null +++ b/src/components/ButtonUnderline/style.module.scss @@ -0,0 +1,25 @@ +@import '../../styles/colors.scss'; + +.button { + color: $primary-main !important; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + + width: max-content; + margin: 0 auto; + + // Override default styling + border: none !important; + outline: none !important; + + // Override leaky css in sign page + background: transparent !important; + + &:focus, + &:hover { + text-decoration: underline; + text-decoration-color: inherit; + } +} From dbcc96aca22e2d70996d9fc84a26592bc7d92055 Mon Sep 17 00:00:00 2001 From: enes Date: Sat, 4 Jan 2025 20:36:14 +0100 Subject: [PATCH 02/17] refactor: split online and offline create --- src/pages/create/index.tsx | 295 +++++++++++++++++++++---------------- 1 file changed, 164 insertions(+), 131 deletions(-) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index bd3893e..3364c83 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -44,7 +44,6 @@ import { generateKeysFile, getHash, hexToNpub, - isOnline, unixNow, npubToHex, queryNip05, @@ -65,6 +64,7 @@ import { Mark } from '../../types/mark.ts' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { + faDownload, faEllipsis, faEye, faFile, @@ -84,6 +84,7 @@ import _, { truncate } from 'lodash' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' import { useImmer } from 'use-immer' +import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx' type FoundUser = Event & { npub: string } @@ -774,30 +775,6 @@ export const CreatePage = () => { .catch(handleUploadError) } - // Manage offline scenarios for signing or viewing the file - const handleOfflineFlow = async ( - encryptedArrayBuffer: ArrayBuffer, - encryptionKey: string - ) => { - const finalZipFile = await createFinalZipFile( - encryptedArrayBuffer, - encryptionKey - ) - - if (!finalZipFile) { - setIsLoading(false) - return - } - - saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`) - - // If user is the next signer, we can navigate directly to sign page - if (signers[0].pubkey === usersPubkey) { - navigate(appPrivateRoutes.sign, { state: { uploadedZip: finalZipFile } }) - } - setIsLoading(false) - } - const generateFilesZip = async (): Promise => { const zip = new JSZip() selectedFiles.forEach((file) => { @@ -863,7 +840,7 @@ export const CreatePage = () => { return e.id } - const handleCreate = async () => { + const initCreation = async () => { try { if (!validateInputs()) return @@ -875,132 +852,183 @@ export const CreatePage = () => { setLoadingSpinnerDesc('Generating encryption key') const encryptionKey = await generateEncryptionKey() - if (await isOnline()) { - setLoadingSpinnerDesc('generating files.zip') - const arrayBuffer = await generateFilesZip() - if (!arrayBuffer) return + setLoadingSpinnerDesc('Creating marks') + const markConfig = createMarks(fileHashes) - setLoadingSpinnerDesc('Encrypting files.zip') - const encryptedArrayBuffer = await encryptZipFile( - arrayBuffer, - encryptionKey - ) + return { + encryptionKey, + markConfig, + fileHashes + } + } catch (error) { + if (error instanceof Error) { + toast.error(error.message) + } + console.error(error) + setIsLoading(false) + } + } - const markConfig = createMarks(fileHashes) + const handleCreate = async () => { + try { + const result = await initCreation() + if (!result) return - setLoadingSpinnerDesc('Uploading files.zip to file storage') - const fileUrl = await uploadFile(encryptedArrayBuffer) - if (!fileUrl) return + const { encryptionKey, markConfig, fileHashes } = result - setLoadingSpinnerDesc('Generating create signature') - const createSignature = await generateCreateSignature( - markConfig, - fileHashes, - fileUrl - ) - if (!createSignature) return + setLoadingSpinnerDesc('generating files.zip') + const arrayBuffer = await generateFilesZip() + if (!arrayBuffer) return - setLoadingSpinnerDesc('Generating keys for decryption') + setLoadingSpinnerDesc('Encrypting files.zip') + const encryptedArrayBuffer = await encryptZipFile( + arrayBuffer, + encryptionKey + ) - // 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!) - } + setLoadingSpinnerDesc('Uploading files.zip to file storage') + const fileUrl = await uploadFile(encryptedArrayBuffer) + if (!fileUrl) return - const keys = await generateKeys(pubkeys, encryptionKey) - if (!keys) return + setLoadingSpinnerDesc('Generating create signature') + const createSignature = await generateCreateSignature( + markConfig, + fileHashes, + fileUrl + ) + if (!createSignature) return - setLoadingSpinnerDesc('Generating an open timestamp.') + setLoadingSpinnerDesc('Generating keys for decryption') - const timestamp = await generateTimestamp( - extractNostrId(createSignature) - ) + // 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 meta: Meta = { - createSignature, - keys, - modifiedAt: unixNow(), - docSignatures: {} - } + const keys = await generateKeys(pubkeys, encryptionKey) + if (!keys) return - if (timestamp) { - meta.timestamps = [timestamp] - } + setLoadingSpinnerDesc('Generating an open timestamp.') - setLoadingSpinnerDesc('Updating user app data') + const timestamp = await generateTimestamp(extractNostrId(createSignature)) - const event = await updateUsersAppData(meta) - if (!event) return + const meta: Meta = { + createSignature, + keys, + modifiedAt: unixNow(), + docSignatures: {} + } - const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + if (timestamp) { + meta.timestamps = [timestamp] + } - setLoadingSpinnerDesc('Sending notifications to counterparties') - const promises = sendNotifications({ - metaUrl, - keys: meta.keys + 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({ + metaUrl, + keys: meta.keys + }) + + await Promise.all(promises) + .then(() => { + toast.success('Notifications sent successfully') + }) + .catch(() => { + toast.error('Failed to publish notifications') }) - await Promise.all(promises) - .then(() => { - toast.success('Notifications sent successfully') - }) - .catch(() => { - toast.error('Failed to publish notifications') - }) + const isFirstSigner = signers[0].pubkey === usersPubkey - const isFirstSigner = signers[0].pubkey === usersPubkey - - if (isFirstSigner) { - navigate(appPrivateRoutes.sign, { state: { meta } }) - } else { - const createSignatureJson = JSON.parse(createSignature) - navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`) - } + if (isFirstSigner) { + navigate(appPrivateRoutes.sign, { state: { meta } }) } else { - const zip = new JSZip() + const createSignatureJson = JSON.parse(createSignature) + navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`) + } + } catch (error) { + if (error instanceof Error) { + toast.error(error.message) + } + console.error(error) + } finally { + setIsLoading(false) + } + } - selectedFiles.forEach((file) => { - zip.file(`files/${file.name}`, file) + const handleCreateOffline = async () => { + try { + const result = await initCreation() + if (!result) return + + const { encryptionKey, markConfig, fileHashes } = result + + const zip = new JSZip() + + selectedFiles.forEach((file) => { + zip.file(`files/${file.name}`, file) + }) + + setLoadingSpinnerDesc('Generating create signature') + const createSignature = await generateCreateSignature( + markConfig, + fileHashes, + '' + ) + if (!createSignature) return + + const meta: Meta = { + createSignature, + modifiedAt: unixNow(), + 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 + ) + + const finalZipFile = await createFinalZipFile( + encryptedArrayBuffer, + encryptionKey + ) + + if (!finalZipFile) { + setIsLoading(false) + return + } + + saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`) + + // If user is the next signer, we can navigate directly to sign page + if (signers[0].pubkey === usersPubkey) { + navigate(appPrivateRoutes.sign, { + state: { uploadedZip: finalZipFile } }) - - const markConfig = createMarks(fileHashes) - - setLoadingSpinnerDesc('Generating create signature') - const createSignature = await generateCreateSignature( - markConfig, - fileHashes, - '' - ) - if (!createSignature) return - - const meta: Meta = { - createSignature, - modifiedAt: unixNow(), - 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) + } else { + navigate(appPrivateRoutes.homePage) } } catch (error) { if (error instanceof Error) { @@ -1258,6 +1286,11 @@ export const CreatePage = () => { Publish + + + Create and export locally + + {!!error && ( {error} )} From 736dafce946e344eeaa993f0c42c058413ba7c9f Mon Sep 17 00:00:00 2001 From: b <> Date: Tue, 7 Jan 2025 20:20:22 +0000 Subject: [PATCH 03/17] chore: testing guidance in contributing.md --- contributing.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/contributing.md b/contributing.md index bac274e..ddff155 100644 --- a/contributing.md +++ b/contributing.md @@ -6,19 +6,19 @@ Welcome to Sigit! We are thrilled that you are interested in contributing to thi ### Reporting Bugs -If you encounter a bug while using Sigit, please [open an issue](https://git.sigit.io/g/web/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug. +If you encounter a bug while using Sigit, please [open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug. ### Suggesting Enhancements -If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.sigit.io/g/web/issues/new) to suggest an enhancement. +If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) to suggest an enhancement. ### Pull Requests We welcome pull requests from contributors! To contribute code changes: -1. Fork the repository and create your branch from `main`. +1. Fork the repository and create your branch from `staging`. 2. Make your changes and ensure they pass any existing tests. -3. Write meaningful commit messages. +3. Write meaningful commit messages (conventional commit standard) 4. Submit a pull request, describing your changes in detail and referencing any related issues. ## Development Setup @@ -35,4 +35,14 @@ All contributions, including pull requests, undergo code review. Code review ens ## Contact -If you have questions or need further assistance, you can reach out to [maintainer's email]. +If you have questions or need further assistance, you can reach out to `npub1d0csynrrxcynkcedktdzrdj6gnras2psg48mf46kxjazs8skrjgq9uzhlq` + +## Testing + +The following items should be tested with each release: + +- Create a SIGit with at least 3 signers +- Create a SIGit where the creator is not the first signer +- Create a SIGit where one co-signer has no marks +- Create a SIGit using a file other than a PDF +- Use several login mechanisms, browsers, operating systems whilst testing From 746338465d1c370b503be257a9d57e132e0d7192 Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 8 Jan 2025 12:29:59 +0200 Subject: [PATCH 04/17] fix: disables redundant metaInNavState updates --- src/pages/sign/index.tsx | 54 +++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 8eb782e..aa5bf74 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -3,7 +3,7 @@ import saveAs from 'file-saver' import JSZip from 'jszip' import _ from 'lodash' import { Event, verifyEvent } from 'nostr-tools' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useAppSelector } from '../../hooks' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' @@ -58,36 +58,37 @@ export const SignPage = () => { const usersAppData = useAppSelector((state) => state.userAppData) + /** + * In the online mode, Sigit ID can be obtained either from the router state + * using location or from UsersAppData + */ + const metaInNavState = useMemo(() => { + if (usersAppData) { + const sigitCreateId = params.id + + if (sigitCreateId) { + const sigit = usersAppData.sigits[sigitCreateId] + + if (sigit) { + return sigit + } + } + } + + return location?.state?.meta || undefined + }, [location, usersAppData, params.id]) + /** * Received from `location.state` * * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json * arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode - * meta (metaInNavState) will be received in navigation from create & home page in online mode */ - let metaInNavState = location?.state?.meta || undefined const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || { decryptedArrayBuffer: undefined, uploadedZip: undefined } - /** - * If userAppData (redux) is available, and we have the route param (sigit id) - * which is actually a `createEventId`, we will fetch a `sigit` - * based on the provided route ID and set fetched `sigit` to the `metaInNavState` - */ - if (usersAppData) { - const sigitCreateId = params.id - - if (sigitCreateId) { - const sigit = usersAppData.sigits[sigitCreateId] - - if (sigit) { - metaInNavState = sigit - } - } - } - const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [isLoading, setIsLoading] = useState(true) @@ -275,7 +276,6 @@ export const SignPage = () => { ) useEffect(() => { - // online mode - from create and home page views if (metaInNavState) { const processSigit = async () => { setIsLoading(true) @@ -310,7 +310,15 @@ export const SignPage = () => { } processSigit() - } else if (decryptedArrayBuffer) { + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + // online mode - from create and home page views + + if (decryptedArrayBuffer) { handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() => setIsLoading(false) ) @@ -329,7 +337,7 @@ export const SignPage = () => { } else { setIsLoading(false) } - }, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt]) + }, [decryptedArrayBuffer, uploadedZip, decrypt]) const handleArrayBufferFromBlossom = async ( arrayBuffer: ArrayBuffer, From 70e7e5305efe697eba88998b462d456de7cf5294 Mon Sep 17 00:00:00 2001 From: Eugene Date: Thu, 9 Jan 2025 12:58:23 +0200 Subject: [PATCH 05/17] refactor: renames to fileHash --- src/components/PDFView/index.tsx | 10 +++++++--- src/data/metaSamples.json | 8 ++++---- src/pages/create/index.tsx | 4 ++-- src/pages/verify/index.tsx | 5 ++++- src/types/mark.ts | 3 ++- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/PDFView/index.tsx b/src/components/PDFView/index.tsx index 95d577e..88a8983 100644 --- a/src/components/PDFView/index.tsx +++ b/src/components/PDFView/index.tsx @@ -38,12 +38,16 @@ const PdfView = ({ currentUserMarks: CurrentUserMark[], hash: string ): CurrentUserMark[] => { - return currentUserMarks.filter( - (currentUserMark) => currentUserMark.mark.pdfFileHash === hash + return currentUserMarks.filter((currentUserMark) => + currentUserMark.mark.pdfFileHash + ? currentUserMark.mark.pdfFileHash === hash + : currentUserMark.mark.fileHash === hash ) } const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => { - return marks.filter((mark) => mark.pdfFileHash === hash) + return marks.filter((mark) => + mark.pdfFileHash ? mark.pdfFileHash === hash : mark.fileHash === hash + ) } return (
diff --git a/src/data/metaSamples.json b/src/data/metaSamples.json index 66f6536..69f595e 100644 --- a/src/data/metaSamples.json +++ b/src/data/metaSamples.json @@ -19,7 +19,7 @@ "page": 1 }, "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", - "pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" + "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" } ], "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": [ @@ -34,7 +34,7 @@ "page": 2 }, "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", - "pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" + "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" } ] } @@ -54,7 +54,7 @@ "page": 1 }, "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", - "pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", + "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", "value": "Pera Peric" }, { @@ -68,7 +68,7 @@ "page": 2 }, "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", - "pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", + "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", "value": "Pera Peric" } ] diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index de9c3d3..ce249b7 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -636,8 +636,8 @@ export const CreatePage = () => { width: drawnField.width }, npub: drawnField.counterpart, - pdfFileHash: fileHash, - fileName: file.name + fileName: file.name, + fileHash } }) }) || [] diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index e39ca44..6b16207 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -105,7 +105,10 @@ const SlimPdfView = ({ const m = parsedSignatureEvents[ e as `npub1${string}` ].parsedContent?.marks.filter( - (m) => m.pdfFileHash == hash && m.location.page == i + (m) => + (m.pdfFileHash + ? m.pdfFileHash == hash + : m.fileHash == hash) && m.location.page == i ) if (m) { marks.push(...m) diff --git a/src/types/mark.ts b/src/types/mark.ts index df733d6..4d67f5d 100644 --- a/src/types/mark.ts +++ b/src/types/mark.ts @@ -11,10 +11,11 @@ export interface CurrentUserMark { export interface Mark { id: number npub: string - pdfFileHash: string type: MarkType location: MarkLocation fileName: string + pdfFileHash?: string + fileHash?: string value?: string } From 3be0fd7bbb99492e899d19d91d2928917644a9d2 Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 14 Jan 2025 10:30:03 +0200 Subject: [PATCH 06/17] chore: adds comment --- src/types/mark.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types/mark.ts b/src/types/mark.ts index 4d67f5d..b540643 100644 --- a/src/types/mark.ts +++ b/src/types/mark.ts @@ -8,6 +8,8 @@ export interface CurrentUserMark { currentValue?: string } +// Both PdfFileHash and FileHash currently exist. +// It enables backward compatibility export interface Mark { id: number npub: string From c69d55c3a893c59ecd68791865c718429aae351b Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 14 Jan 2025 10:32:28 +0200 Subject: [PATCH 07/17] chore: adds comment --- src/types/mark.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/mark.ts b/src/types/mark.ts index b540643..e8ea327 100644 --- a/src/types/mark.ts +++ b/src/types/mark.ts @@ -9,7 +9,7 @@ export interface CurrentUserMark { } // Both PdfFileHash and FileHash currently exist. -// It enables backward compatibility +// It enables backward compatibility for Sigits created before January 2025 export interface Mark { id: number npub: string From bcd57138caeb03b03f5b9a4df403534d076a4a15 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 17 Jan 2025 21:02:18 +0100 Subject: [PATCH 08/17] feat(offline): add signer service util class --- src/services/index.ts | 1 + src/services/signer/index.ts | 143 +++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/services/signer/index.ts diff --git a/src/services/index.ts b/src/services/index.ts index 79b5128..b8d275a 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1 +1,2 @@ export * from './cache' +export * from './signer' diff --git a/src/services/signer/index.ts b/src/services/signer/index.ts new file mode 100644 index 0000000..8851028 --- /dev/null +++ b/src/services/signer/index.ts @@ -0,0 +1,143 @@ +import { toast } from 'react-toastify' +import { Meta, SignedEventContent } from '../../types' +import { + parseCreateSignatureEventContent, + parseNostrEvent, + SigitStatus, + SignStatus +} from '../../utils' +import { MetaParseError } from '../../types/errors/MetaParseError' +import { verifyEvent } from 'nostr-tools' +import { appPrivateRoutes, appPublicRoutes } from '../../routes' + +export class SignerService { + #signers: `npub1${string}`[] = [] + #nextSigner: `npub1${string}` | undefined + #signatures = new Map<`npub1${string}`, string>() + #signersStatus = new Map<`npub1${string}`, SignStatus>() + #lastSignerSig: string | undefined + constructor(source: Meta) { + this.#process(source.createSignature, source.docSignatures) + } + + getNextSigner = () => { + return this.#nextSigner + } + + isNextSigner = (npub: `npub1${string}`) => { + return this.#nextSigner === npub + } + + isLastSigner = (npub: `npub1${string}`) => { + const lastIndex = this.#signers.length - 1 + const npubIndex = this.#signers.indexOf(npub) + return npubIndex === lastIndex + } + + #isFullySigned = () => { + const signedBy = Object.keys(this.#signatures) as `npub1${string}`[] + const isCompletelySigned = this.#signers.every((signer) => + signedBy.includes(signer) + ) + return isCompletelySigned + } + + getSignedStatus = () => { + return this.#isFullySigned() ? SigitStatus.Complete : SigitStatus.Partial + } + + getSignerStatus = (npub: `npub1${string}`) => { + return this.#signersStatus.get(npub) + } + + getNavigate = (npub: `npub1${string}`) => { + return this.isNextSigner(npub) + ? appPrivateRoutes.sign + : appPublicRoutes.verify + } + + getLastSignerSig = () => { + return this.#lastSignerSig + } + + #process = ( + createSignature: string, + docSignatures: { [key: `npub1${string}`]: string } + ) => { + try { + const createSignatureEvent = parseNostrEvent(createSignature) + const { signers } = parseCreateSignatureEventContent( + createSignatureEvent.content + ) + const getPrevSignerSig = (npub: `npub1${string}`) => { + if (signers[0] === npub) { + return createSignatureEvent.sig + } + + // Find the index of signer + const currentSignerIndex = signers.findIndex( + (signer) => signer === npub + ) + + // Return if could not found user in signer's list + if (currentSignerIndex === -1) return + + // Find prev signer + const prevSigner = signers[currentSignerIndex - 1] + + // Get the signature of prev signer + return this.#signatures.get(prevSigner) + } + + this.#signers = [...signers] + for (const npub in docSignatures) { + try { + // Parse each signature event + const event = parseNostrEvent(docSignatures[npub as `npub1${string}`]) + this.#signatures.set(npub as `npub1${string}`, event.sig) + const isValidSignature = verifyEvent(event) + if (isValidSignature) { + const prevSignersSig = getPrevSignerSig(npub as `npub1${string}`) + const signedEvent: SignedEventContent = JSON.parse(event.content) + if ( + signedEvent.prevSig && + prevSignersSig && + signedEvent.prevSig === prevSignersSig + ) { + this.#signersStatus.set( + npub as `npub1${string}`, + SignStatus.Signed + ) + this.#lastSignerSig = event.sig + } + } else { + this.#signersStatus.set( + npub as `npub1${string}`, + SignStatus.Invalid + ) + } + } catch (error) { + this.#signersStatus.set(npub as `npub1${string}`, SignStatus.Invalid) + } + } + + this.#signers + .filter((s) => !this.#signatures.has(s)) + .forEach((s) => this.#signersStatus.set(s, SignStatus.Pending)) + + // Get the first signer that hasn't signed + const nextSigner = this.#signers.find((s) => !this.#signatures.has(s)) + if (nextSigner) { + this.#nextSigner = nextSigner + this.#signersStatus.set(nextSigner, SignStatus.Awaiting) + } + } catch (error) { + if (error instanceof MetaParseError) { + toast.error(error.message) + console.error(error.name, error.message, error.cause, error.context) + } else { + console.error('Unexpected error', error) + } + } + } +} From 7b2537e35549daf5f600518dc3876e525d149ee2 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 17 Jan 2025 21:05:59 +0100 Subject: [PATCH 09/17] refactor(offline): useSigitMeta and remove async ops when parsing json --- src/hooks/useSigitMeta.tsx | 66 +++++--------------------------------- src/types/core.ts | 46 +++++++++++++++++++++++--- src/utils/meta.ts | 19 +++++------ 3 files changed, 59 insertions(+), 72 deletions(-) diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 5c1159e..2026621 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -1,11 +1,5 @@ import { useEffect, useState } from 'react' -import { - CreateSignatureEventContent, - DocSignatureEvent, - Meta, - SignedEventContent, - OpenTimestamp -} from '../types' +import { DocSignatureEvent, Meta, SignedEventContent, FlatMeta } from '../types' import { Mark } from '../types/mark' import { fromUnixTimestamp, @@ -17,53 +11,11 @@ import { } from '../utils' import { toast } from 'react-toastify' import { verifyEvent } from 'nostr-tools' -import { Event } from 'nostr-tools' import store from '../store/store' import { NostrController } from '../controllers' import { MetaParseError } from '../types/errors/MetaParseError' import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy' -/** - * Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`, - * and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions) - */ -export interface FlatMeta - extends Meta, - CreateSignatureEventContent, - Partial> { - // Remove pubkey and use submittedBy as `npub1${string}` - submittedBy?: `npub1${string}` - - // Optional field only present on exported sigits - // Exporting adds user's pubkey - exportedBy?: `npub1${string}` - - // Remove created_at and replace with createdAt - createdAt?: number - - // Validated create signature event - isValid: boolean - - // Decryption - encryptionKey: string | undefined - - // Parsed Document Signatures - parsedSignatureEvents: { - [signer: `npub1${string}`]: DocSignatureEvent - } - - // Calculated completion time - completedAt?: number - - // Calculated status fields - signedStatus: SigitStatus - signersStatus: { - [signer: `npub1${string}`]: SignStatus - } - - timestamps?: OpenTimestamp[] -} - /** * Custom use hook for parsing the Sigit Meta * @param meta Sigit Meta @@ -74,8 +26,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { const [kind, setKind] = useState() const [tags, setTags] = useState() const [createdAt, setCreatedAt] = useState() - const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event - const [exportedBy, setExportedBy] = useState<`npub1${string}`>() // pubkey from export signature nostr event + const [submittedBy, setSubmittedBy] = useState() // submittedBy, pubkey from nostr event (hex) + const [exportedBy, setExportedBy] = useState() // pubkey from export signature nostr event (hex) const [id, setId] = useState() const [sig, setSig] = useState() @@ -108,18 +60,16 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { ;(async function () { try { if (meta.exportSignature) { - const exportSignatureEvent = await parseNostrEvent( - meta.exportSignature - ) + const exportSignatureEvent = parseNostrEvent(meta.exportSignature) if ( verifyEvent(exportSignatureEvent) && exportSignatureEvent.pubkey ) { - setExportedBy(exportSignatureEvent.pubkey as `npub1${string}`) + setExportedBy(exportSignatureEvent.pubkey) } } - const createSignatureEvent = await parseNostrEvent(meta.createSignature) + const createSignatureEvent = parseNostrEvent(meta.createSignature) const { kind, tags, created_at, pubkey, id, sig, content } = createSignatureEvent @@ -129,12 +79,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setTags(tags) // created_at in nostr events are stored in seconds setCreatedAt(fromUnixTimestamp(created_at)) - setSubmittedBy(pubkey as `npub1${string}`) + setSubmittedBy(pubkey) setId(id) setSig(sig) const { title, signers, viewers, fileHashes, markConfig, zipUrl } = - await parseCreateSignatureEventContent(content) + parseCreateSignatureEventContent(content) setTitle(title) setSigners(signers) diff --git a/src/types/core.ts b/src/types/core.ts index f07dbf7..932ebf5 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -1,6 +1,7 @@ import { Mark } from './mark' import { Keys } from '../store/auth/types' import { Event } from 'nostr-tools' +import { SigitStatus, SignStatus } from '../utils' export enum UserRole { signer = 'Signer', @@ -35,11 +36,6 @@ export interface SignedEventContent { marks: Mark[] } -export interface Sigit { - fileUrl: string - meta: Meta -} - export interface OpenTimestamp { nostrId: string value: string @@ -92,3 +88,43 @@ export interface SigitNotification { export function isSigitNotification(obj: unknown): obj is SigitNotification { return typeof (obj as SigitNotification).metaUrl === 'string' } + +/** + * Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`, + * and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions) + */ +export interface FlatMeta + extends Meta, + CreateSignatureEventContent, + Partial> { + submittedBy?: string + + // Optional field only present on exported sigits + // Exporting adds user's pubkey + exportedBy?: string + + // Remove created_at and replace with createdAt + createdAt?: number + + // Validated create signature event + isValid: boolean + + // Decryption + encryptionKey: string | undefined + + // Parsed Document Signatures + parsedSignatureEvents: { + [signer: `npub1${string}`]: DocSignatureEvent + } + + // Calculated completion time + completedAt?: number + + // Calculated status fields + signedStatus: SigitStatus + signersStatus: { + [signer: `npub1${string}`]: SignStatus + } + + timestamps?: OpenTimestamp[] +} diff --git a/src/utils/meta.ts b/src/utils/meta.ts index 8052abf..75e1654 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -49,9 +49,9 @@ export interface SigitCardDisplayInfo { * @param raw Raw string for parsing * @returns parsed Event */ -export const parseNostrEvent = async (raw: string): Promise => { +export const parseNostrEvent = (raw: string): Event => { try { - const event = await parseJson(raw) + const event = JSON.parse(raw) as Event return event } catch (error) { throw new MetaParseError(MetaParseErrorType.PARSE_ERROR_EVENT, { @@ -66,12 +66,13 @@ export const parseNostrEvent = async (raw: string): Promise => { * @param raw Raw string for parsing * @returns parsed CreateSignatureEventContent */ -export const parseCreateSignatureEventContent = async ( +export const parseCreateSignatureEventContent = ( raw: string -): Promise => { +): CreateSignatureEventContent => { try { - const createSignatureEventContent = - await parseJson(raw) + const createSignatureEventContent = JSON.parse( + raw + ) as CreateSignatureEventContent return createSignatureEventContent } catch (error) { throw new MetaParseError( @@ -89,7 +90,7 @@ export const parseCreateSignatureEventContent = async ( * @param meta Sigit metadata * @returns SigitCardDisplayInfo */ -export const extractSigitCardDisplayInfo = async (meta: Meta) => { +export const extractSigitCardDisplayInfo = (meta: Meta) => { if (!meta?.createSignature) return const sigitInfo: SigitCardDisplayInfo = { @@ -100,14 +101,14 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } try { - const createSignatureEvent = await parseNostrEvent(meta.createSignature) + const createSignatureEvent = parseNostrEvent(meta.createSignature) sigitInfo.isValid = verifyEvent(createSignatureEvent) // created_at in nostr events are stored in seconds sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at) - const createSignatureContent = await parseCreateSignatureEventContent( + const createSignatureContent = parseCreateSignatureEventContent( createSignatureEvent.content ) From 8b5abe02e2b9d3f3101afa014c4fa4655ed4b099 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 17 Jan 2025 21:06:55 +0100 Subject: [PATCH 10/17] feat(offline): add decrypt as zip util --- src/utils/zip.ts | 140 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 4 deletions(-) diff --git a/src/utils/zip.ts b/src/utils/zip.ts index 577dd86..485c3ea 100644 --- a/src/utils/zip.ts +++ b/src/utils/zip.ts @@ -1,6 +1,12 @@ import JSZip from 'jszip' import { toast } from 'react-toastify' -import { InputFileFormat, OutputByType, OutputType } from '../types' +import { InputFileFormat, Meta, OutputByType, OutputType } from '../types' +import { NavigateOptions, To } from 'react-router-dom' +import { appPublicRoutes } from '../routes' +import { NostrController } from '../controllers' +import { decryptArrayBuffer } from './crypto' +import { hexToNpub, parseJson, SigitStatus, timeout } from '.' +import { SignerService } from '../services' /** * Read the content of a file within a zip archive. @@ -9,7 +15,7 @@ import { InputFileFormat, OutputByType, OutputType } from '../types' * @param outputType The type of output to return (e.g., 'string', 'arraybuffer', 'uint8array', etc.). * @returns A Promise resolving to the content of the file, or null if an error occurs. */ -const readContentOfZipEntry = async ( +export const readContentOfZipEntry = async ( zip: JSZip, filePath: string, outputType: T @@ -34,7 +40,7 @@ const readContentOfZipEntry = async ( }) } -const loadZip = async (data: InputFileFormat): Promise => { +export const loadZip = async (data: InputFileFormat): Promise => { try { return await JSZip.loadAsync(data) } catch (err) { @@ -46,4 +52,130 @@ const loadZip = async (data: InputFileFormat): Promise => { } } -export { readContentOfZipEntry, loadZip } +export const decrypt = async (file: File) => { + const nostrController = NostrController.getInstance() + + const zip = await loadZip(file) + if (!zip) return + + const keysFileContent = await readContentOfZipEntry( + zip, + 'keys.json', + 'string' + ) + + if (!keysFileContent) return null + + const parsedKeysJson = await parseJson<{ sender: string; keys: string[] }>( + keysFileContent + ).catch((err) => { + console.log(`Error parsing content of keys.json:`, err) + toast.error(err.message || `Error parsing content of keys.json`) + return null + }) + + if (!parsedKeysJson) return + + const encryptedArrayBuffer = await readContentOfZipEntry( + zip, + 'compressed.sigit', + 'arraybuffer' + ) + + if (!encryptedArrayBuffer) return + + const { keys, sender } = parsedKeysJson + + for (const key of keys) { + // decrypt the encryptionKey, with timeout (duration = 60 seconds) + const encryptionKey = await Promise.race([ + nostrController.nip04Decrypt(sender, key), + timeout(60000) + ]) + .then((res) => { + return res + }) + .catch((err) => { + console.log('err :>> ', err) + return null + }) + + // Return if encryption failed + if (!encryptionKey) continue + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer, + encryptionKey + ).catch((err) => { + console.log('err in decryption:>> ', err) + return null + }) + + if (arrayBuffer) return arrayBuffer + } + + return null +} + +type NavigateArgs = { to: To; options?: NavigateOptions } +export const navigateFromZip = async (file: File, pubkey: `npub1${string}`) => { + if (!file.name.endsWith('.sigit.zip')) { + toast.error(`Not a SiGit zip file: ${file.name}`) + } + + try { + let zip = await JSZip.loadAsync(file) + if (!zip) { + return null + } + + let arrayBuffer: ArrayBuffer | undefined + if ('keys.json' in zip.files) { + // Decrypt + const decryptedArrayBuffer = await decrypt(file).catch((err) => { + console.error(`error occurred in decryption`, err) + toast.error(err.message || `error occurred in decryption`) + }) + + if (decryptedArrayBuffer) { + // Replace the zip and continue processing + zip = await JSZip.loadAsync(decryptedArrayBuffer) + arrayBuffer = decryptedArrayBuffer + } + } + + if ('meta.json' in zip.files) { + // Check where we need to navigate + // Find Meta and process it for signer state + const metaContent = await readContentOfZipEntry( + zip, + 'meta.json', + 'string' + ) + if (metaContent) { + const meta = JSON.parse(metaContent) as Meta + const signerService = new SignerService(meta) + + const to = + signerService.getSignedStatus() === SigitStatus.Complete + ? appPublicRoutes.verify + : signerService.getNavigate(hexToNpub(pubkey)) + + return { + to, + options: { + state: { uploadedZip: arrayBuffer || file } + } + } as NavigateArgs + } + } + + return null + } catch (err) { + console.error('err in processing sigit zip file :>> ', err) + if (err instanceof Error) { + toast.error(err.message || 'An error occurred in loading zip file.') + } + return null + } +} From b6a84dedbeaaf59ad09771465c439eaf206401e6 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 17 Jan 2025 21:07:47 +0100 Subject: [PATCH 11/17] refactor(offline): remove unused function --- src/utils/mark.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/utils/mark.ts b/src/utils/mark.ts index f455ea9..1868403 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -270,21 +270,10 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => { return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label } -export const getOptimizedPathsWithStrokeWidth = (svgString: string) => { - const parser = new DOMParser() - const xmlDoc = parser.parseFromString(svgString, 'image/svg+xml') - const paths = xmlDoc.querySelectorAll('path') - const tuples: string[][] = [] - paths.forEach((path) => { - const d = path.getAttribute('d') ?? '' - const strokeWidth = path.getAttribute('stroke-width') ?? '' - tuples.push([d, strokeWidth]) - }) - - return tuples -} - -export const processMarks = async (marks: Mark[], encryptionKey?: string) => { +export const encryptAndUploadMarks = async ( + marks: Mark[], + encryptionKey?: string +) => { const _marks = [...marks] for (let i = 0; i < _marks.length; i++) { const mark = _marks[i] From b7410c7d338612eb607901d0a8cdbb4f554cd232 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 17 Jan 2025 21:09:38 +0100 Subject: [PATCH 12/17] refactor(offline): make both export types as optional --- src/components/ButtonUnderline/index.tsx | 2 +- src/components/FileList/index.tsx | 72 +++++++++++++----------- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/components/ButtonUnderline/index.tsx b/src/components/ButtonUnderline/index.tsx index 510b2a9..8301d90 100644 --- a/src/components/ButtonUnderline/index.tsx +++ b/src/components/ButtonUnderline/index.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react' import styles from './style.module.scss' interface ButtonUnderlineProps { - onClick: () => void + onClick: (event: React.MouseEvent) => void disabled?: boolean | undefined } diff --git a/src/components/FileList/index.tsx b/src/components/FileList/index.tsx index a073ca4..45e2631 100644 --- a/src/components/FileList/index.tsx +++ b/src/components/FileList/index.tsx @@ -1,16 +1,16 @@ -import { CurrentUserFile } from '../../types/file.ts' -import styles from './style.module.scss' +import React from 'react' import { Button, Menu, MenuItem } from '@mui/material' +import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state' 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' +import { CurrentUserFile } from '../../types/file.ts' +import styles from './style.module.scss' interface FileListProps { files: CurrentUserFile[] currentFile: CurrentUserFile setCurrentFile: (file: CurrentUserFile) => void - handleExport: () => void + handleExport?: () => void handleEncryptedExport?: () => void } @@ -45,34 +45,40 @@ const FileList = ({ ))} - - {(popupState) => ( - - - - { - popupState.close - handleExport() - }} - > - Export Files - - { - popupState.close - typeof handleEncryptedExport === 'function' && - handleEncryptedExport() - }} - > - Export Encrypted Files - - - - )} - + {(typeof handleExport === 'function' || + typeof handleEncryptedExport === 'function') && ( + + {(popupState) => ( + + + + {typeof handleExport === 'function' && ( + { + popupState.close + handleExport() + }} + > + Export Files + + )} + {typeof handleEncryptedExport === 'function' && ( + { + popupState.close + handleEncryptedExport() + }} + > + Export Encrypted Files + + )} + + + )} + + )}
) } From 3f01ab8fcaf7aa94460215418315f56190e4f4b0 Mon Sep 17 00:00:00 2001 From: enes Date: Fri, 17 Jan 2025 21:12:31 +0100 Subject: [PATCH 13/17] feat(offline): split online and offline flow with dedicated buttons, remove export in sign, all counterparties can decrypt --- src/components/MarkFormField/index.tsx | 48 ++- .../MarkTypeStrategy/Signature/index.tsx | 26 +- src/components/PDFView/PdfMarking.tsx | 42 +- src/pages/create/index.tsx | 16 +- src/pages/home/index.tsx | 34 +- src/pages/sign/index.tsx | 383 ++++-------------- src/pages/verify/index.tsx | 117 +++--- src/utils/sign.ts | 37 +- 8 files changed, 237 insertions(+), 466 deletions(-) diff --git a/src/components/MarkFormField/index.tsx b/src/components/MarkFormField/index.tsx index fde0922..43c94a9 100644 --- a/src/components/MarkFormField/index.tsx +++ b/src/components/MarkFormField/index.tsx @@ -8,15 +8,19 @@ import { 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 { faCheck, faDownload } from '@fortawesome/free-solid-svg-icons' import { Button } from '@mui/material' import styles from './style.module.scss' +import { ButtonUnderline } from '../ButtonUnderline/index.tsx' interface MarkFormFieldProps { currentUserMarks: CurrentUserMark[] handleCurrentUserMarkChange: (mark: CurrentUserMark) => void handleSelectedMarkValueChange: (value: string) => void - handleSubmit: (event: React.MouseEvent) => void + handleSubmit: ( + event: React.MouseEvent, + type: 'online' | 'offline' + ) => void selectedMark: CurrentUserMark | null selectedMarkValue: string } @@ -73,11 +77,11 @@ const MarkFormField = ({ setComplete(true) } - const handleSignAndComplete = ( - event: React.MouseEvent - ) => { - handleSubmit(event) - } + const handleSignAndComplete = + (type: 'online' | 'offline') => + (event: React.MouseEvent) => { + handleSubmit(event, type) + } return (
@@ -129,18 +133,28 @@ const MarkFormField = ({
) : ( -
- +
+ - SIGN AND COMPLETE - - + + Sign and export locally instead + + )}
diff --git a/src/components/MarkTypeStrategy/Signature/index.tsx b/src/components/MarkTypeStrategy/Signature/index.tsx index 5915ab2..aa3429d 100644 --- a/src/components/MarkTypeStrategy/Signature/index.tsx +++ b/src/components/MarkTypeStrategy/Signature/index.tsx @@ -3,7 +3,6 @@ import { decryptArrayBuffer, encryptArrayBuffer, getHash, - isOnline, uploadToFileStorage } from '../../../utils' import { MarkStrategy } from '../MarkStrategy' @@ -37,21 +36,17 @@ export const SignatureStrategy: MarkStrategy = { // Create the encrypted json file from array buffer and hash const file = new File([encryptedArrayBuffer], `${hash}.json`) - if (await isOnline()) { - try { - const url = await uploadToFileStorage(file) - console.info(`${file.name} uploaded to file storage`) - return url - } catch (error) { - if (error instanceof Error) { - console.error( - `Error occurred in uploading file ${file.name}`, - error.message - ) - } + try { + const url = await uploadToFileStorage(file) + console.info(`${file.name} uploaded to file storage`) + return url + } catch (error) { + if (error instanceof Error) { + console.error( + `Error occurred in uploading file ${file.name}`, + error.message + ) } - } else { - // TOOD: offline } return value @@ -89,7 +84,6 @@ export const SignatureStrategy: MarkStrategy = { return json } - // TOOD: offline return value } } diff --git a/src/components/PDFView/PdfMarking.tsx b/src/components/PDFView/PdfMarking.tsx index cbd3907..94e3333 100644 --- a/src/components/PDFView/PdfMarking.tsx +++ b/src/components/PDFView/PdfMarking.tsx @@ -24,9 +24,8 @@ import { interface PdfMarkingProps { currentUserMarks: CurrentUserMark[] files: CurrentUserFile[] - handleExport: () => void - handleEncryptedExport: () => void handleSign: () => void + handleSignOffline: () => void meta: Meta | null otherUserMarks: Mark[] setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void @@ -39,18 +38,16 @@ interface PdfMarkingProps { * @param props * @constructor */ -const PdfMarking = (props: PdfMarkingProps) => { - const { - files, - currentUserMarks, - setCurrentUserMarks, - setUpdatedMarks, - handleExport, - handleEncryptedExport, - handleSign, - meta, - otherUserMarks - } = props +const PdfMarking = ({ + files, + currentUserMarks, + setCurrentUserMarks, + setUpdatedMarks, + handleSign, + handleSignOffline, + meta, + otherUserMarks +}: PdfMarkingProps) => { const [selectedMark, setSelectedMark] = useState(null) const [selectedMarkValue, setSelectedMarkValue] = useState('') const [currentFile, setCurrentFile] = useState(null) @@ -99,7 +96,10 @@ const PdfMarking = (props: PdfMarkingProps) => { /** * Sign and Complete */ - const handleSubmit = (event: React.MouseEvent) => { + const handleSubmit = ( + event: React.MouseEvent, + type: 'online' | 'offline' + ) => { event.preventDefault() if (selectedMarkValue && selectedMark) { const updatedMark: CurrentUserMark = getUpdatedMark( @@ -117,16 +117,10 @@ const PdfMarking = (props: PdfMarkingProps) => { setUpdatedMarks(updatedMark.mark) } - handleSign() + if (type === 'online') handleSign() + else if (type === 'offline') handleSignOffline() } - // const updateCurrentUserMarkValues = () => { - // const updatedMark: CurrentUserMark = getUpdatedMark(selectedMark!, selectedMarkValue) - // const updatedCurrentUserMarks = updateCurrentUserMarks(currentUserMarks, updatedMark) - // setSelectedMarkValue(EMPTY) - // setCurrentUserMarks(updatedCurrentUserMarks) - // } - const handleChange = (value: string) => { setSelectedMarkValue(value) } @@ -142,8 +136,6 @@ const PdfMarking = (props: PdfMarkingProps) => { files={files} currentFile={currentFile} setCurrentFile={setCurrentFile} - handleExport={handleExport} - handleEncryptedExport={handleEncryptedExport} /> )}
diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 4b7989c..cd287b6 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -693,10 +693,18 @@ export const CreatePage = () => { type: 'application/sigit' }) - const firstSigner = users.filter((user) => user.role === UserRole.signer)[0] - + const userSet = new Set() + const nostrController = NostrController.getInstance() + const pubkey = await nostrController.capturePublicKey() + userSet.add(pubkey) + signers.forEach((signer) => { + userSet.add(signer.pubkey) + }) + viewers.forEach((viewer) => { + userSet.add(viewer.pubkey) + }) const keysFileContent = await generateKeysFile( - [firstSigner.pubkey], + Array.from(userSet), encryptionKey ) if (!keysFileContent) return null @@ -998,7 +1006,7 @@ export const CreatePage = () => { // If user is the next signer, we can navigate directly to sign page if (signers[0].pubkey === usersPubkey) { navigate(appPrivateRoutes.sign, { - state: { uploadedZip: finalZipFile } + state: { arrayBuffer } }) } else { navigate(appPrivateRoutes.homePage) diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index d81dd1b..abd3b4e 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,10 +1,9 @@ import { Button, TextField } from '@mui/material' -import JSZip from 'jszip' import { useCallback, useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' import { useAppSelector } from '../../hooks' -import { appPrivateRoutes, appPublicRoutes } from '../../routes' +import { appPrivateRoutes } from '../../routes' import { Meta } from '../../types' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSearch } from '@fortawesome/free-solid-svg-icons' @@ -15,6 +14,7 @@ import { Container } from '../../components/Container' import styles from './style.module.scss' import { extractSigitCardDisplayInfo, + navigateFromZip, SigitCardDisplayInfo, SigitStatus } from '../../utils' @@ -56,6 +56,7 @@ export const HomePage = () => { [key: string]: SigitCardDisplayInfo }>({}) const usersAppData = useAppSelector((state) => state.userAppData) + const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) useEffect(() => { if (usersAppData?.sigits) { @@ -63,7 +64,7 @@ export const HomePage = () => { const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {} for (const key in usersAppData.sigits) { if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) { - const sigitInfo = await extractSigitCardDisplayInfo( + const sigitInfo = extractSigitCardDisplayInfo( usersAppData.sigits[key] ) if (sigitInfo) { @@ -92,27 +93,12 @@ export const HomePage = () => { const fileName = file.name const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters if (fileExtension === '.sigit.zip') { - const zip = await JSZip.loadAsync(file).catch((err) => { - console.log('err in loading zip file :>> ', err) - toast.error(err.message || 'An error occurred in loading zip file.') - return null - }) + const nav = await navigateFromZip( + file, + usersPubkey as `npub1${string}` + ) - if (!zip) return - - // navigate to sign page if zip contains keys.json - if ('keys.json' in zip.files) { - return navigate(appPrivateRoutes.sign, { - state: { uploadedZip: file } - }) - } - - // navigate to verify page if zip contains meta.json - if ('meta.json' in zip.files) { - return navigate(appPublicRoutes.verify, { - state: { uploadedZip: file } - }) - } + if (nav) return navigate(nav.to, nav.options) toast.error('Invalid SiGit zip file') return @@ -124,7 +110,7 @@ export const HomePage = () => { state: { uploadedFiles: acceptedFiles } }) }, - [navigate] + [navigate, usersPubkey] ) const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index aa5bf74..07ffd4d 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -1,54 +1,41 @@ import axios from 'axios' -import saveAs from 'file-saver' import JSZip from 'jszip' import _ from 'lodash' import { Event, verifyEvent } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useAppSelector } from '../../hooks' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' -import { appPrivateRoutes, appPublicRoutes } from '../../routes' +import { appPublicRoutes } from '../../routes' import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { - ARRAY_BUFFER, decryptArrayBuffer, - DEFLATE, - encryptArrayBuffer, extractMarksFromSignedMeta, extractZipUrlAndEncryptionKey, filterMarksByPubkey, findOtherUserMarks, - generateEncryptionKey, - generateKeysFile, getCurrentUserFiles, getCurrentUserMarks, getHash, hexToNpub, - isOnline, loadZip, npubToHex, parseJson, - processMarks, + encryptAndUploadMarks, readContentOfZipEntry, signEventForMetaFile, - timeout, unixNow, updateMarks, uploadMetaToFileStorage } from '../../utils' import { CurrentUserMark, Mark } from '../../types/mark.ts' import PdfMarking from '../../components/PDFView/PdfMarking.tsx' -import { - convertToSigitFile, - getZipWithFiles, - SigitFile -} from '../../utils/file.ts' +import { convertToSigitFile, SigitFile } from '../../utils/file.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' import { useNDK } from '../../hooks/useNDK.ts' -import { getLastSignersSig } from '../../utils/sign.ts' export const SignPage = () => { const navigate = useNavigate() @@ -81,12 +68,10 @@ export const SignPage = () => { /** * Received from `location.state` * - * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json * arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode */ const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || { - decryptedArrayBuffer: undefined, - uploadedZip: undefined + decryptedArrayBuffer: undefined } const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) @@ -218,63 +203,6 @@ export const SignPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [meta, usersPubkey]) - const decrypt = useCallback( - async (file: File) => { - setLoadingSpinnerDesc('Decrypting file') - - const zip = await loadZip(file) - if (!zip) return - - const parsedKeysJson = await parseKeysJson(zip) - if (!parsedKeysJson) return - - const encryptedArrayBuffer = await readContentOfZipEntry( - zip, - 'compressed.sigit', - 'arraybuffer' - ) - - if (!encryptedArrayBuffer) return - - const { keys, sender } = parsedKeysJson - - for (const key of keys) { - // decrypt the encryptionKey, with timeout (duration = 60 seconds) - const encryptionKey = await Promise.race([ - nostrController.nip04Decrypt(sender, key), - timeout(60000) - ]) - .then((res) => { - return res - }) - .catch((err) => { - console.log('err :>> ', err) - return null - }) - - // Return if encryption failed - if (!encryptionKey) continue - - const arrayBuffer = await decryptArrayBuffer( - encryptedArrayBuffer, - encryptionKey - ) - .catch((err) => { - console.log('err in decryption:>> ', err) - return null - }) - .finally(() => { - setIsLoading(false) - }) - - if (arrayBuffer) return arrayBuffer - } - - return null - }, - [nostrController] - ) - useEffect(() => { if (metaInNavState) { const processSigit = async () => { @@ -316,28 +244,14 @@ export const SignPage = () => { }, []) useEffect(() => { - // online mode - from create and home page views - - if (decryptedArrayBuffer) { - handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() => - setIsLoading(false) + if (decryptedArrayBuffer || uploadedZip) { + handleDecryptedArrayBuffer(decryptedArrayBuffer || uploadedZip).finally( + () => setIsLoading(false) ) - } else if (uploadedZip) { - decrypt(uploadedZip) - .then((arrayBuffer) => { - if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) - }) - .catch((err) => { - console.error(`error occurred in decryption`, err) - toast.error(err.message || `error occurred in decryption`) - }) - .finally(() => { - setIsLoading(false) - }) } else { setIsLoading(false) } - }, [decryptedArrayBuffer, uploadedZip, decrypt]) + }, [decryptedArrayBuffer, uploadedZip]) const handleArrayBufferFromBlossom = async ( arrayBuffer: ArrayBuffer, @@ -396,30 +310,12 @@ export const SignPage = () => { setMarks(updatedMarks) } - const parseKeysJson = async (zip: JSZip) => { - const keysFileContent = await readContentOfZipEntry( - zip, - 'keys.json', - 'string' - ) - - if (!keysFileContent) return null - - return await parseJson<{ sender: string; keys: string[] }>( - keysFileContent - ).catch((err) => { - console.log(`Error parsing content of keys.json:`, err) - toast.error(err.message || `Error parsing content of keys.json`) - return null - }) - } - - const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => { - const decryptedZipFile = new File([arrayBuffer], 'decrypted.zip') - + const handleDecryptedArrayBuffer = async ( + decryptedArrayBuffer: ArrayBuffer + ) => { setLoadingSpinnerDesc('Parsing zip file') - const zip = await loadZip(decryptedZipFile) + const zip = await loadZip(decryptedArrayBuffer) if (!zip) return const files: { [filename: string]: SigitFile } = {} @@ -479,16 +375,15 @@ export const SignPage = () => { setMeta(parsedMetaJson) } - const handleSign = async () => { + const initializeSigning = async (type: 'online' | 'offline') => { if (Object.entries(files).length === 0 || !meta) return setIsLoading(true) - setLoadingSpinnerDesc('Signing nostr event') + const usersNpub = hexToNpub(usersPubkey!) const prevSig = getPrevSignersSig(usersNpub) if (!prevSig) { - setIsLoading(false) toast.error('Previous signature is invalid') return } @@ -508,7 +403,10 @@ export const SignPage = () => { }) } - const processedMarks = await processMarks(marks, encryptionKey) + const processedMarks = + type === 'online' + ? await encryptAndUploadMarks(marks, encryptionKey) + : marks const signedEvent = await signEventForMeta({ prevSig, @@ -519,6 +417,22 @@ export const SignPage = () => { const updatedMeta = updateMetaSignatures(meta, signedEvent) + return { + encryptionKey, + updatedMeta, + signedEvent + } + } + + const handleSign = async () => { + const result = await initializeSigning('online') + if (!result) { + setIsLoading(false) + return + } + + const { encryptionKey, updatedMeta, signedEvent } = result + setLoadingSpinnerDesc('Generating an open timestamp.') const timestamp = await generateTimestamp(signedEvent.id) @@ -527,19 +441,62 @@ export const SignPage = () => { updatedMeta.modifiedAt = unixNow() } - if (await isOnline()) { - await handleOnlineFlow(updatedMeta, encryptionKey) - } else { - setMeta(updatedMeta) + await handleOnlineFlow(updatedMeta, encryptionKey) + + const createSignature = JSON.parse(updatedMeta.createSignature) + navigate(`${appPublicRoutes.verify}/${createSignature.id}`) + } + + const handleSignOffline = async () => { + const result = await initializeSigning('offline') + if (!result) { setIsLoading(false) + return } - if (metaInNavState) { - const createSignature = JSON.parse(metaInNavState.createSignature) - navigate(`${appPublicRoutes.verify}/${createSignature.id}`) - } else { - navigate(appPrivateRoutes.homePage) + const { updatedMeta } = result + + const zip = new JSZip() + for (const [filename, value] of Object.entries(files)) { + zip.file(`files/${filename}`, await value.arrayBuffer()) } + const stringifiedMeta = JSON.stringify(updatedMeta, null, 2) + zip.file('meta.json', stringifiedMeta) + + // 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 + } + + setLoadingSpinnerDesc('Generating zip file') + + const arrayBuffer = await zip + .generateAsync({ + type: 'arraybuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }) + .catch(handleZipError) + + if (!arrayBuffer) { + setIsLoading(false) + return + } + + // Create a File object with the Blob data + const blob = new Blob([arrayBuffer]) + const file = new File([blob], `request-${unixNow()}.sigit.zip`, { + type: 'application/zip' + }) + + setIsLoading(false) + + navigate(`${appPublicRoutes.verify}`, { state: { uploadedZip: file } }) } // Sign the event for the meta file @@ -570,66 +527,6 @@ export const SignPage = () => { return metaCopy } - // 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' - }) - } - // Check if the current user is the last signer const checkIsLastSigner = (signers: string[]): boolean => { const usersNpub = hexToNpub(usersPubkey!) @@ -638,16 +535,6 @@ export const SignPage = () => { return signerIndex === lastSignerIndex } - // 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 - } - // Handle the online flow: update users app data and send notifications const handleOnlineFlow = async ( meta: Meta, @@ -718,99 +605,6 @@ export const SignPage = () => { setIsLoading(false) } - const handleExport = async () => { - const arrayBuffer = await prepareZipExport() - if (!arrayBuffer) return - - const blob = new Blob([arrayBuffer]) - saveAs(blob, `exported-${unixNow()}.sigit.zip`) - - setIsLoading(false) - - navigate(appPublicRoutes.verify) - } - - 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 ( - !signers.includes(usersNpub) && - !viewers.includes(usersNpub) && - submittedBy !== usersNpub - ) - return Promise.resolve(null) - - setIsLoading(true) - setLoadingSpinnerDesc('Signing nostr event') - - if (!meta) return Promise.resolve(null) - - const prevSig = getLastSignersSig(meta, signers) - if (!prevSig) return Promise.resolve(null) - - const signedEvent = await signEventForMetaFile( - JSON.stringify({ - prevSig - }), - nostrController, - setIsLoading - ) - - if (!signedEvent) return Promise.resolve(null) - - const exportSignature = JSON.stringify(signedEvent, null, 2) - - const stringifiedMeta = JSON.stringify( - { - ...meta, - exportSignature - }, - null, - 2 - ) - - const zip = await getZipWithFiles(meta, files) - zip.file('meta.json', stringifiedMeta) - - const arrayBuffer = await zip - .generateAsync({ - type: ARRAY_BUFFER, - compression: DEFLATE, - compressionOptions: { - level: 6 - } - }) - .catch((err) => { - console.log('err in zip:>> ', err) - setIsLoading(false) - toast.error(err.message || 'Error occurred in generating zip file') - return null - }) - - if (!arrayBuffer) return Promise.resolve(null) - - return Promise.resolve(arrayBuffer) - } - /** * This function accepts an npub of a signer and return the signature of its previous signer. * This prevSig will be used in the content of the provided signer's signedEvent @@ -855,8 +649,7 @@ export const SignPage = () => { setCurrentUserMarks={setCurrentUserMarks} setUpdatedMarks={setUpdatedMarks} handleSign={handleSign} - handleExport={handleExport} - handleEncryptedExport={handleEncryptedExport} + handleSignOffline={handleSignOffline} otherUserMarks={otherUserMarks} meta={meta} /> diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index e39ca44..402decb 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -1,7 +1,7 @@ import { Box, Button, Typography } from '@mui/material' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' @@ -27,14 +27,14 @@ import { generateKeysFile, ARRAY_BUFFER, DEFLATE, - uploadMetaToFileStorage + uploadMetaToFileStorage, + decrypt } from '../../utils' import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' import axios from 'axios' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' import { useAppSelector, useNDK } from '../../hooks' -import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' @@ -60,6 +60,7 @@ import { import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts' import _ from 'lodash' import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx' +import { SignerService } from '../../services/index.ts' interface PdfViewProps { files: CurrentUserFile[] @@ -185,7 +186,7 @@ export const VerifyPage = () => { * meta will be received in navigation from create & home page in online mode */ let metaInNavState = location?.state?.meta || undefined - const { uploadedZip } = location.state || {} + const uploadedZip = location?.state?.uploadedZip || undefined const [selectedFile, setSelectedFile] = useState(null) /** @@ -205,12 +206,6 @@ export const VerifyPage = () => { } } - useEffect(() => { - if (uploadedZip) { - setSelectedFile(uploadedZip) - } - }, [uploadedZip]) - const [meta, setMeta] = useState(metaInNavState) const { @@ -480,17 +475,35 @@ export const VerifyPage = () => { } }, [encryptionKey, metaInNavState, zipUrl]) - const handleVerify = async () => { - if (!selectedFile) return + const handleVerify = useCallback(async (selectedFile: File) => { setIsLoading(true) + setLoadingSpinnerDesc('Loading zip file') - const zip = await JSZip.loadAsync(selectedFile).catch((err) => { + let zip = await JSZip.loadAsync(selectedFile).catch((err) => { console.log('err in loading zip file :>> ', err) toast.error(err.message || 'An error occurred in loading zip file.') return null }) - if (!zip) return + if (!zip) { + return setIsLoading(false) + } + + if ('keys.json' in zip.files) { + // Decrypt + setLoadingSpinnerDesc('Decrypting zip file content') + const arrayBuffer = await decrypt(selectedFile).catch((err) => { + console.error(`error occurred in decryption`, err) + toast.error(err.message || `error occurred in decryption`) + }) + + if (arrayBuffer) { + // Replace the zip and continue processing + zip = await JSZip.loadAsync(arrayBuffer) + } + } + + setLoadingSpinnerDesc('Opening zip file content') const files: { [filename: string]: SigitFile } = {} const fileHashes: { [key: string]: string | null } = {} @@ -547,12 +560,21 @@ export const VerifyPage = () => { } ) - if (!parsedMetaJson) return + if (!parsedMetaJson) { + setIsLoading(false) + return + } setMeta(parsedMetaJson) setIsLoading(false) - } + }, []) + + useEffect(() => { + if (uploadedZip) { + handleVerify(uploadedZip) + } + }, [handleVerify, uploadedZip]) // Handle errors during zip file generation const handleZipError = (err: unknown) => { @@ -564,14 +586,6 @@ export const VerifyPage = () => { 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, @@ -584,28 +598,16 @@ export const VerifyPage = () => { 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)!) + if (submittedBy) { + userSet.add(submittedBy) } + signers.forEach((signer) => { + userSet.add(npubToHex(signer)!) + }) + viewers.forEach((viewer) => { + userSet.add(npubToHex(viewer)!) + }) const keysFileContent = await generateKeysFile( Array.from(userSet), @@ -634,7 +636,10 @@ export const VerifyPage = () => { const handleExport = async () => { const arrayBuffer = await prepareZipExport() - if (!arrayBuffer) return + if (!arrayBuffer) { + setIsLoading(false) + return + } const blob = new Blob([arrayBuffer]) saveAs(blob, `exported-${unixNow()}.sigit.zip`) @@ -644,7 +649,10 @@ export const VerifyPage = () => { const handleEncryptedExport = async () => { const arrayBuffer = await prepareZipExport() - if (!arrayBuffer) return + if (!arrayBuffer) { + setIsLoading(false) + return + } const key = await generateEncryptionKey() @@ -653,7 +661,11 @@ export const VerifyPage = () => { const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) - if (!finalZipFile) return + if (!finalZipFile) { + setIsLoading(false) + return + } + saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`) setIsLoading(false) @@ -675,7 +687,11 @@ export const VerifyPage = () => { setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') - const prevSig = getLastSignersSig(meta, signers) + if (!meta) return Promise.resolve(null) + + const signerService = new SignerService(meta) + const prevSig = signerService.getLastSignerSig() + if (!prevSig) return Promise.resolve(null) const signedEvent = await signEventForMetaFile( @@ -736,7 +752,10 @@ export const VerifyPage = () => { {selectedFile && ( - diff --git a/src/utils/sign.ts b/src/utils/sign.ts index ff67e44..dc3410c 100644 --- a/src/utils/sign.ts +++ b/src/utils/sign.ts @@ -1,46 +1,11 @@ -import { Event } from 'nostr-tools' -import { Meta } from '../types' - -/** - * This function returns the signature of last signer - * It will be used in the content of export signature's signedEvent - */ -const getLastSignersSig = ( - meta: Meta, - signers: `npub1${string}`[] -): string | null => { - // if there're no signers then use creator's signature - if (signers.length === 0) { - try { - const createSignatureEvent: Event = JSON.parse(meta.createSignature) - return createSignatureEvent.sig - } catch (error) { - return null - } - } - - // get last signer - const lastSigner = signers[signers.length - 1] - - // get the signature of last signer - try { - const lastSignatureEvent: Event = JSON.parse(meta.docSignatures[lastSigner]) - return lastSignatureEvent.sig - } catch (error) { - return null - } -} - /** * Checks if all signers have signed the sigit * @param signers - an array of npubs of all signers from the Sigit * @param signedBy - an array of npubs that have signed it already */ -const isFullySigned = ( +export const isFullySigned = ( signers: `npub1${string}`[], signedBy: `npub1${string}`[] ): boolean => { return signers.every((signer) => signedBy.includes(signer)) } - -export { getLastSignersSig, isFullySigned } From 99d562a3edb62b09664091946a59e88020460d39 Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 20 Jan 2025 19:41:14 +0100 Subject: [PATCH 14/17] feat(export): add icons and make encrypted be first/top option --- src/components/FileList/index.tsx | 36 ++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/components/FileList/index.tsx b/src/components/FileList/index.tsx index 45e2631..9a52772 100644 --- a/src/components/FileList/index.tsx +++ b/src/components/FileList/index.tsx @@ -2,7 +2,11 @@ import React from 'react' import { Button, Menu, MenuItem } from '@mui/material' import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCheck } from '@fortawesome/free-solid-svg-icons' +import { + faCheck, + faLock, + faTriangleExclamation +} from '@fortawesome/free-solid-svg-icons' import { CurrentUserFile } from '../../types/file.ts' import styles from './style.module.scss' @@ -54,16 +58,6 @@ const FileList = ({ Export files - {typeof handleExport === 'function' && ( - { - popupState.close - handleExport() - }} - > - Export Files - - )} {typeof handleEncryptedExport === 'function' && ( { @@ -71,7 +65,25 @@ const FileList = ({ handleEncryptedExport() }} > - Export Encrypted Files + +   EXPORT (encrypted) + + )} + {typeof handleExport === 'function' && ( + { + popupState.close + handleExport() + }} + > + +   EXPORT (unencrypted) )} From 04f1d692a44123331129ee92443c92f9254403f4 Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 20 Jan 2025 20:06:18 +0100 Subject: [PATCH 15/17] fix(relays): allow adding ws:// Closes #297 --- src/pages/settings/relays/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index b2c102e..e210ac5 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -54,7 +54,10 @@ export const RelaysPage = () => { const relayMap = useAppSelector((state) => state.relays?.map) const relaysInfo = useAppSelector((state) => state.relays?.info) - const webSocketPrefix = 'wss://' + const webSocketPrefix = + newRelayURI?.startsWith('wss://') || newRelayURI?.startsWith('ws://') + ? '' + : 'wss://' // fetch relay list from relays useEffect(() => { @@ -197,7 +200,7 @@ export const RelaysPage = () => { // Check if new relay URI is a valid string if ( relayURI && - !/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( + !/^wss?:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( relayURI ) ) { From 5f3d92d62f1f958f3e93ed4c9cd879c88a5c5d6c Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 20 Jan 2025 20:14:44 +0100 Subject: [PATCH 16/17] fix(relays): relay add button size height Closes #244 --- src/pages/settings/relays/index.tsx | 8 +++++++- src/pages/settings/relays/style.module.scss | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index e210ac5..c0542c5 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -263,7 +263,13 @@ export const RelaysPage = () => { }} className={styles.relayURItextfield} /> - diff --git a/src/pages/settings/relays/style.module.scss b/src/pages/settings/relays/style.module.scss index a5654c5..3db7760 100644 --- a/src/pages/settings/relays/style.module.scss +++ b/src/pages/settings/relays/style.module.scss @@ -12,6 +12,7 @@ flex-direction: row; gap: 10px; width: 100%; + align-items: start; } .sectionIcon { From a4310675c10f07b030410774587f6fc25b6b2bb5 Mon Sep 17 00:00:00 2001 From: enes Date: Mon, 20 Jan 2025 21:24:18 +0100 Subject: [PATCH 17/17] refactor(offline): update strings, create offline navigate to sign/verify w/o auto download --- src/components/FileList/index.tsx | 4 ++-- src/pages/create/index.tsx | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/FileList/index.tsx b/src/components/FileList/index.tsx index 9a52772..62f3119 100644 --- a/src/components/FileList/index.tsx +++ b/src/components/FileList/index.tsx @@ -69,7 +69,7 @@ const FileList = ({ color={'var(--mui-palette-primary-main)'} icon={faLock} /> -   EXPORT (encrypted) +   ENCRYPTED )} {typeof handleExport === 'function' && ( @@ -83,7 +83,7 @@ const FileList = ({ color={'var(--mui-palette-primary-main)'} icon={faTriangleExclamation} /> -   EXPORT (unencrypted) +   UNENCRYPTED )} diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 5589f68..d5424a9 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -8,7 +8,6 @@ import { Tooltip } from '@mui/material' import type { Identifier, XYCoord } from 'dnd-core' -import saveAs from 'file-saver' import JSZip from 'jszip' import { useCallback, useEffect, useRef, useState } from 'react' import { DndProvider, useDrag, useDrop } from 'react-dnd' @@ -928,7 +927,6 @@ export const CreatePage = () => { }) const isFirstSigner = signers[0].pubkey === usersPubkey - if (isFirstSigner) { navigate(appPrivateRoutes.sign, { state: { meta } }) } else { @@ -1001,15 +999,16 @@ export const CreatePage = () => { return } - saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`) - // If user is the next signer, we can navigate directly to sign page - if (signers[0].pubkey === usersPubkey) { + const isFirstSigner = signers[0].pubkey === usersPubkey + if (isFirstSigner) { navigate(appPrivateRoutes.sign, { state: { arrayBuffer } }) } else { - navigate(appPrivateRoutes.homePage) + navigate(appPublicRoutes.verify, { + state: { uploadedZip: arrayBuffer } + }) } } catch (error) { if (error instanceof Error) {