diff --git a/.gitea/workflows/release-production.yaml b/.gitea/workflows/release-production.yaml index 334eb11..d9529ad 100644 --- a/.gitea/workflows/release-production.yaml +++ b/.gitea/workflows/release-production.yaml @@ -29,4 +29,6 @@ jobs: - name: Release Build run: | npm -g install cloudron-surfer - surfer put --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io dist/* / + surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io + surfer put dist/* / --all -d + surfer put dist/.well-known / --all diff --git a/.gitea/workflows/release-staging.yaml b/.gitea/workflows/release-staging.yaml index 0467f63..793c70c 100644 --- a/.gitea/workflows/release-staging.yaml +++ b/.gitea/workflows/release-staging.yaml @@ -29,4 +29,6 @@ jobs: - name: Release Build run: | npm -g install cloudron-surfer - surfer put --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server staging.sigit.io dist/* / + surfer config --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server staging.sigit.io + surfer put dist/* / --all -d + surfer put dist/.well-known / --all diff --git a/public/.well-known/nostr.json b/public/.well-known/nostr.json new file mode 100644 index 0000000..6dd4dd9 --- /dev/null +++ b/public/.well-known/nostr.json @@ -0,0 +1,15 @@ +{ + "names": { + "_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90" + }, + "relays": { + "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [ + "wss://brb.io", + "wss://nostr.v0l.io", + "wss://nostr.coinos.io", + "wss://rsslay.nostr.net", + "wss://relay.current.fyi", + "wss://nos.io" + ] + } +} \ No newline at end of file diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 049f99a..ecc9cc9 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -378,6 +378,61 @@ export class NostrController extends EventEmitter { throw new Error('Login method is undefined') } + /** + * Decrypts a given content based on the current login method. + * + * @param sender - The sender's public key. + * @param content - The encrypted content to decrypt. + * @returns A promise that resolves to the decrypted content. + */ + nip04Decrypt = async (sender: string, content: string) => { + const loginMethod = (store.getState().auth as AuthState).loginMethod + + if (loginMethod === LoginMethods.extension) { + const nostr = this.getNostrObject() + + if (!nostr.nip04) { + throw new Error( + `Your nostr extension does not support nip04 encryption & decryption` + ) + } + + const decrypted = await nostr.nip04.decrypt(sender, content) + return decrypted + } + + if (loginMethod === LoginMethods.privateKey) { + const keys = (store.getState().auth as AuthState).keyPair + + if (!keys) { + throw new Error( + `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` + ) + } + + const { private: nsec } = keys + const privateKey = nip19.decode(nsec).data as Uint8Array + + const decrypted = await nip04.decrypt(privateKey, sender, content) + return decrypted + } + + if (loginMethod === LoginMethods.nsecBunker) { + const user = new NDKUser({ pubkey: sender }) + + this.remoteSigner?.on('authUrl', (authUrl) => { + this.emit('nsecbunker-auth', authUrl) + }) + + if (!this.remoteSigner) throw new Error('Remote signer is undefined.') + const decrypted = await this.remoteSigner.decrypt(user, content) + + return decrypted + } + + throw new Error('Login method is undefined') + } + /** * Function will capture the public key from the nostr extension or if no extension present * function wil capture the public key from the local storage diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 70ae698..8d07935 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -22,7 +22,7 @@ import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserComponent } from '../../components/username' @@ -33,6 +33,7 @@ import { Meta, ProfileMetadata, User, UserRole } from '../../types' import { encryptArrayBuffer, generateEncryptionKey, + generateKeysFile, getHash, hexToNpub, isOnline, @@ -49,16 +50,16 @@ import { HTML5Backend } from 'react-dnd-html5-backend' import type { Identifier, XYCoord } from 'dnd-core' import { useDrag, useDrop } from 'react-dnd' import saveAs from 'file-saver' -import CopyModal from '../../components/copyModal' import { Event, kinds } from 'nostr-tools' import { DrawPDFFields } from '../../components/DrawPDFFields' export const CreatePage = () => { const navigate = useNavigate() + const location = useLocation() + const { uploadedFile } = location.state || {} + const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const [openCopyModal, setOpenCopyModel] = useState(false) - const [textToCopy, setTextToCopy] = useState('') const [authUrl, setAuthUrl] = useState() @@ -75,6 +76,12 @@ export const CreatePage = () => { const nostrController = NostrController.getInstance() + useEffect(() => { + if (uploadedFile) { + setSelectedFiles([uploadedFile]) + } + }, [uploadedFile]) + useEffect(() => { if (usersPubkey) { setUsers((prev) => { @@ -222,57 +229,67 @@ export const CreatePage = () => { ) } - const handleCreate = async () => { + // Validate inputs before proceeding + const validateInputs = (): boolean => { if (!title.trim()) { toast.error('Title can not be empty') - return + return false } if (users.length === 0) { toast.error( - 'No signer/viewer is provided. At least add one signer or viewer.' + 'No signer/viewer is provided. At least add one signer or viewer.' ) - return + return false } if (selectedFiles.length === 0) { toast.error('No file is selected. Select at least 1 file') - return + return false } - setIsLoading(true) - setLoadingSpinnerDesc('Generating hashes for files') + return true + } + // Handle errors during file arrayBuffer conversion + const handleFileError = (file: File) => (err: any) => { + console.log( + `Error while getting arrayBuffer of file ${file.name} :>> `, + err + ) + toast.error( + err.message || `Error while getting arrayBuffer of file ${file.name}` + ) + return null + } + + // Generate hash for each selected file + const generateFileHashes = async (): Promise<{ + [key: string]: string + } | null> => { const fileHashes: { [key: string]: string } = {} - // generating file hashes for (const file of selectedFiles) { - const arraybuffer = await file.arrayBuffer().catch((err) => { - console.log( - `err while getting arrayBuffer of file ${file.name} :>> `, - err - ) - toast.error( - err.message || `err while getting arrayBuffer of file ${file.name}` - ) - return null - }) - - if (!arraybuffer) return + const arraybuffer = await file.arrayBuffer().catch(handleFileError(file)) + if (!arraybuffer) return null const hash = await getHash(arraybuffer) - if (!hash) { - setIsLoading(false) - return + return null } fileHashes[file.name] = hash } + return fileHashes + } + + // Create a zip file with the selected files and sign the event + const createZipFile = async (fileHashes: { + [key: string]: string + }): Promise<{ zip: JSZip; createSignature: string } | null> => { const zip = new JSZip() - // zipping files selectedFiles.forEach((file) => { zip.file(`files/${file.name}`, file) }) @@ -281,6 +298,7 @@ export const CreatePage = () => { const viewers = users.filter((user) => user.role === UserRole.viewer) setLoadingSpinnerDesc('Signing nostr event') + const createSignature = await signEventForMetaFile( JSON.stringify({ signers: signers.map((signer) => hexToNpub(signer.pubkey)), @@ -291,12 +309,27 @@ export const CreatePage = () => { setIsLoading ) - if (!createSignature) return + if (!createSignature) return null + try { + return { + zip, + createSignature: JSON.stringify(createSignature, null, 2) + } + } catch (error) { + return null + } + } + + // Add metadata and file hashes to the zip file + const addMetaToZip = async ( + zip: JSZip, + createSignature: string + ): Promise => { // create content for meta file const meta: Meta = { title, - createSignature: JSON.stringify(createSignature, null, 2), + createSignature, docSignatures: {} } @@ -305,113 +338,221 @@ export const CreatePage = () => { zip.file('meta.json', stringifiedMeta) const metaHash = await getHash(stringifiedMeta) - if (!metaHash) return + if (!metaHash) return null const metaHashJson = { [usersPubkey!]: metaHash } zip.file('hashes.json', JSON.stringify(metaHashJson, null, 2)) + return metaHash } catch (err) { console.error(err) toast.error('An error occurred in converting meta json to string') - return + return null } + } + // Handle errors during zip file generation + const handleZipError = (err: any) => { + console.log('Error in zip:>> ', err) + setIsLoading(false) + toast.error(err.message || 'Error occurred in generating zip file') + return null + } + + // Generate the zip file + const generateZipFile = async (zip: JSZip): Promise => { setLoadingSpinnerDesc('Generating zip file') const arraybuffer = await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', - compressionOptions: { - level: 6 - } - }) - .catch((err) => { - console.log('err in zip:>> ', err) - setIsLoading(false) - toast.error(err.message || 'Error occurred in generating zip file') - return null + compressionOptions: { level: 6 } }) + .catch(handleZipError) - if (!arraybuffer) return + return arraybuffer + } + + // Encrypt the zip file with the generated encryption key + const encryptZipFile = async ( + arraybuffer: ArrayBuffer, + encryptionKey: string + ): Promise => { + setLoadingSpinnerDesc('Encrypting zip file') + return encryptArrayBuffer(arraybuffer, encryptionKey) + } + + // create final zip file + const createFinalZipFile = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ): Promise => { + // Get the current timestamp in seconds + const unixNow = Math.floor(Date.now() / 1000) + const blob = new Blob([encryptedArrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed.sigit`, { + type: 'application/sigit' + }) + + const firstSigner = users.filter((user) => user.role === UserRole.signer)[0] + + const keysFileContent = await generateKeysFile( + [firstSigner.pubkey], + encryptionKey + ) + if (!keysFileContent) return null + + const zip = new JSZip() + zip.file(`compressed.sigit`, file) + zip.file('keys.json', keysFileContent) + + const arraybuffer = await zip + .generateAsync({ + type: 'arraybuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }) + .catch(handleZipError) + + if (!arraybuffer) return null + + const finalZipFile = new File( + [new Blob([arraybuffer])], + `${unixNow}.sigit.zip`, + { + type: 'application/zip' + } + ) + + return finalZipFile + } + + const handleOnlineFlow = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ) => { + const unixNow = Math.floor(Date.now() / 1000) + const blob = new Blob([encryptedArrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed-${unixNow}.sigit`, { + type: 'application/sigit' + }) + + const fileUrl = await uploadFile(file) + if (!fileUrl) return + + await sendDMs(fileUrl, encryptionKey) + } + + // Handle errors during file upload + const handleUploadError = (err: any) => { + console.log('Error in upload:>> ', err) + setIsLoading(false) + toast.error(err.message || 'Error occurred in uploading file') + return null + } + + // Upload the file to the storage + const uploadFile = async (file: File): Promise => { + setIsLoading(true) + setLoadingSpinnerDesc('Uploading sigit to file storage.') + + const fileUrl = await uploadToFileStorage(file, nostrController) + .then((url) => { + toast.success('Sigit uploaded to file storage') + return url + }) + .catch(handleUploadError) + + return fileUrl + } + + // Send DMs to signers and viewers with the file URL + const sendDMs = async (fileUrl: string, encryptionKey: string) => { + setLoadingSpinnerDesc('Sending DM to signers/viewers') + + const signers = users.filter((user) => user.role === UserRole.signer) + const viewers = users.filter((user) => user.role === UserRole.viewer) + + if (signers.length > 0) { + await sendDM( + fileUrl, + encryptionKey, + signers[0].pubkey, + nostrController, + true, + setAuthUrl + ) + } else { + for (const viewer of viewers) { + await sendDM( + fileUrl, + encryptionKey, + viewer.pubkey, + nostrController, + false, + setAuthUrl + ) + } + } + } + + // Manage offline scenarios for signing or viewing the file + const handleOfflineFlow = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ) => { + const finalZipFile = await createFinalZipFile( + encryptedArrayBuffer, + encryptionKey + ) + + if (!finalZipFile) return + + saveAs(finalZipFile, 'request.sigit.zip') + } + + const handleCreate = async () => { + if (!validateInputs()) return + + setIsLoading(true) + setLoadingSpinnerDesc('Generating hashes for files') + + const fileHashes = await generateFileHashes() + if (!fileHashes) return + + const createZipResponse = await createZipFile(fileHashes) + if (!createZipResponse) return + + const { zip, createSignature } = createZipResponse + + const metaHash = await addMetaToZip(zip, createSignature) + if (!metaHash) return + + setLoadingSpinnerDesc('Generating zip file') + + const arrayBuffer = await generateZipFile(zip) + if (!arrayBuffer) return const encryptionKey = await generateEncryptionKey() setLoadingSpinnerDesc('Encrypting zip file') - const encryptedArrayBuffer = await encryptArrayBuffer( - arraybuffer, + const encryptedArrayBuffer = await encryptZipFile( + arrayBuffer, encryptionKey - ).finally(() => setIsLoading(false)) - - const blob = new Blob([encryptedArrayBuffer]) + ) if (await isOnline()) { - setIsLoading(true) - setLoadingSpinnerDesc('Uploading zip file to file storage.') - const fileUrl = await uploadToFileStorage(blob, nostrController) - .then((url) => { - toast.success('zip file uploaded to file storage') - return url - }) - .catch((err) => { - console.log('err in upload:>> ', err) - setIsLoading(false) - toast.error(err.message || 'Error occurred in uploading zip file') - return null - }) - - if (!fileUrl) return - - setLoadingSpinnerDesc('Sending DM to signers/viewers') - - // send DM to first signer if exists - if (signers.length > 0) { - await sendDM( - fileUrl, - encryptionKey, - signers[0].pubkey, - nostrController, - true, - setAuthUrl - ) - } else { - // send DM to all viewers if no signer - for (const viewer of viewers) { - // todo: execute in parallel - await sendDM( - fileUrl, - encryptionKey, - viewer.pubkey, - nostrController, - false, - setAuthUrl - ) - } - - } - setIsLoading(false) - - navigate( - `${appPrivateRoutes.sign}?file=${encodeURIComponent( - fileUrl - )}&key=${encodeURIComponent(encryptionKey)}` - ) + await handleOnlineFlow(encryptedArrayBuffer, encryptionKey) } else { - if (signers[0] && signers[0].pubkey === usersPubkey) { - // Create a File object with the Blob data - const file = new File([blob], `compressed.sigit`, { - type: 'application/sigit' - }) - - navigate(appPrivateRoutes.sign, { state: { file, encryptionKey } }) - } else { - saveAs(blob, 'request.sigit') - setTextToCopy(encryptionKey) - setOpenCopyModel(true) - } + await handleOfflineFlow(encryptedArrayBuffer, encryptionKey) } + + navigate(appPrivateRoutes.sign, { state: { arrayBuffer } }) } if (authUrl) { @@ -512,15 +653,6 @@ export const CreatePage = () => { - { - setOpenCopyModel(false) - navigate(appPrivateRoutes.sign) - }} - title="Decryption key for Sigit file" - textToCopy={textToCopy} - /> ) } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 0afb321..181b67d 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -5,13 +5,63 @@ import { PersonOutline, Upload } from '@mui/icons-material' -import { Box, Button, Typography } from '@mui/material' +import { Box, Button, Tooltip, Typography } from '@mui/material' import { useNavigate } from 'react-router-dom' -import { appPrivateRoutes } from '../../routes' +import { appPrivateRoutes, appPublicRoutes } from '../../routes' import styles from './style.module.scss' +import { useRef } from 'react' +import JSZip from 'jszip' +import { toast } from 'react-toastify' export const HomePage = () => { const navigate = useNavigate() + const fileInputRef = useRef(null) + + const handleUploadClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click() + } + } + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0] + if (file) { + // Check if the file extension is .sigit.zip + const fileName = file.name + const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters + if (fileExtension === '.sigit.zip') { + const zip = await JSZip.loadAsync(file).catch((err) => { + console.log('err in loading zip file :>> ', err) + toast.error(err.message || 'An error occurred in loading zip file.') + return null + }) + + if (!zip) return + + // navigate to sign page if zip contains keys.json + if ('keys.json' in zip.files) { + return navigate(appPrivateRoutes.sign, { + state: { uploadedZip: file } + }) + } + + // navigate to verify page if zip contains meta.json + if ('meta.json' in zip.files) { + return navigate(appPublicRoutes.verify, { + state: { uploadedZip: file } + }) + } + + toast.error('Invalid zip file') + return + } + + // navigate to create page + navigate(appPrivateRoutes.create, { state: { uploadedFile: file } }) + } + } return ( @@ -19,11 +69,26 @@ export const HomePage = () => { Sigits - + {/* This is for desktop view */} + + @@ -35,45 +100,96 @@ export const HomePage = () => { Create + {/* This is for mobile view */} + + + + + + + + + + + + + - - - ) } const PlaceHolder = () => { return ( - - - - - - Title - - - - Sigit - - - - 07 Jun 10:23 AM + + + + + Title + + + + Sigit + + + + 07 Jun 10:23 AM + + + + + + Sent + placeholder@sigit.io - - - - Sent - - placeholder@sigit.io - - - - Awaiting - - placeholder@sigit.io - + + + Awaiting + + placeholder@sigit.io diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index 69c5019..e722d70 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -15,7 +15,6 @@ } .actionButtons { - display: flex; justify-content: center; align-items: center; gap: 10px; @@ -25,6 +24,7 @@ .submissions { display: flex; flex-direction: column; + gap: 10px; .item { display: flex; @@ -33,10 +33,10 @@ .titleBox { display: flex; - flex-direction: column; align-items: flex-start; + justify-content: space-between; padding: 10px; - background-color: #e7e2df99; + background-color: #cdc8c499; border-top-left-radius: inherit; border-bottom-left-radius: inherit; diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 1833c77..b49d11e 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -1,66 +1,36 @@ -import { - Box, - Button, - IconButton, - List, - ListItem, - ListSubheader, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TextField, - Tooltip, - Typography, - useTheme -} from '@mui/material' +import { Box, Button, Typography } from '@mui/material' import axios from 'axios' import saveAs from 'file-saver' import JSZip from 'jszip' import _ from 'lodash' import { MuiFileInput } from 'mui-file-input' -import { Event, kinds, verifyEvent } from 'nostr-tools' +import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' -import { useNavigate, useSearchParams, useLocation } from 'react-router-dom' +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' -import { UserComponent } from '../../components/username' -import { MetadataController, NostrController } from '../../controllers' +import { NostrController } from '../../controllers' import { appPublicRoutes } from '../../routes' import { State } from '../../store/rootReducer' -import { - CreateSignatureEventContent, - Meta, - ProfileMetadata, - SignedEventContent, - User, - UserRole -} from '../../types' +import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { decryptArrayBuffer, encryptArrayBuffer, generateEncryptionKey, + generateKeysFile, getHash, hexToNpub, - parseJson, + isOnline, npubToHex, + parseJson, readContentOfZipEntry, sendDM, - shorten, signEventForMetaFile, - uploadToFileStorage, - isOnline + uploadToFileStorage } from '../../utils' +import { DisplayMeta } from './internal/displayMeta' import styles from './style.module.scss' -import { - Cancel, - CheckCircle, - Download, - HourglassTop -} from '@mui/icons-material' -import CopyModal from '../../components/copyModal' enum SignedStatus { Fully_Signed, User_Is_Next_Signer, @@ -70,21 +40,19 @@ enum SignedStatus { export const SignPage = () => { const navigate = useNavigate() const location = useLocation() - const { file, encryptionKey: encKey } = location.state || {} + const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = + location.state || {} const [searchParams, setSearchParams] = useSearchParams() const [displayInput, setDisplayInput] = useState(false) const [selectedFile, setSelectedFile] = useState(null) - const [encryptionKey, setEncryptionKey] = useState('') const [zip, setZip] = useState() const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') - const [openCopyModal, setOpenCopyModel] = useState(false) - const [textToCopy, setTextToCopy] = useState('') const [meta, setMeta] = useState(null) const [signedStatus, setSignedStatus] = useState() @@ -203,13 +171,22 @@ export const SignPage = () => { .get(fileUrl, { responseType: 'arraybuffer' }) - .then((res) => { + .then(async (res) => { const fileName = fileUrl.split('/').pop() const file = new File([res.data], fileName!) - decrypt(file, decodeURIComponent(key)).then((arrayBuffer) => { - if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) + const encryptedArrayBuffer = await file.arrayBuffer() + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer, + key + ).catch((err) => { + console.log('err in decryption:>> ', err) + toast.error(err.message || 'An error occurred in decrypting file.') + return null }) + + if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) }) .catch((err) => { console.error(`error occurred in getting file from ${fileUrl}`, err) @@ -220,8 +197,12 @@ export const SignPage = () => { .finally(() => { setIsLoading(false) }) - } else if (file && encKey) { - decrypt(file, decodeURIComponent(encKey)) + } else if (decryptedArrayBuffer) { + handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() => + setIsLoading(false) + ) + } else if (uploadedZip) { + decrypt(uploadedZip) .then((arrayBuffer) => { if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer) }) @@ -236,24 +217,99 @@ export const SignPage = () => { setIsLoading(false) setDisplayInput(true) } - }, [searchParams, file, encKey]) + }, [searchParams, decryptedArrayBuffer, uploadedZip]) - const decrypt = async (file: File, key: string) => { + const parseKeysJson = async (zip: JSZip) => { + const keysFileContent = await readContentOfZipEntry( + zip, + 'keys.json', + 'string' + ) + + if (!keysFileContent) return null + + const parsedJSON = await parseJson<{ sender: string; keys: string[] }>( + keysFileContent + ).catch((err) => { + console.log(`Error parsing content of keys.json:`, err) + toast.error(err.message || `Error parsing content of keys.json`) + return null + }) + + return parsedJSON + } + + const decrypt = async (file: File) => { setLoadingSpinnerDesc('Decrypting file') - const encryptedArrayBuffer = await file.arrayBuffer() + const zip = await JSZip.loadAsync(file).catch((err) => { + console.log('err in loading zip file :>> ', err) + toast.error(err.message || 'An error occurred in loading zip file.') + return null + }) + if (!zip) return - const arrayBuffer = await decryptArrayBuffer(encryptedArrayBuffer, key) - .catch((err) => { - console.log('err in decryption:>> ', err) - toast.error(err.message || 'An error occurred in decrypting file.') - return null - }) - .finally(() => { - setIsLoading(false) + const parsedKeysJson = await parseKeysJson(zip) + if (!parsedKeysJson) return + + const encryptedArrayBuffer = await readContentOfZipEntry( + zip, + 'compressed.sigit', + 'arraybuffer' + ) + + if (!encryptedArrayBuffer) return + + const { keys, sender } = parsedKeysJson + + for (const key of keys) { + // Set up event listener for authentication event + nostrController.on('nsecbunker-auth', (url) => { + setAuthUrl(url) }) - return arrayBuffer + // Set up timeout promise to handle encryption timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Timeout occurred')) + }, 60000) // Timeout duration = 60 seconds + }) + + // decrypt the encryptionKey, with timeout + const encryptionKey = await Promise.race([ + nostrController.nip04Decrypt(sender, key), + timeoutPromise + ]) + .then((res) => { + return res + }) + .catch((err) => { + console.log('err :>> ', err) + return null + }) + .finally(() => { + setAuthUrl(undefined) // Clear authentication URL + }) + + // Return if encryption failed + if (!encryptionKey) continue + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer, + encryptionKey + ) + .catch((err) => { + console.log('err in decryption:>> ', err) + return null + }) + .finally(() => { + setIsLoading(false) + }) + + if (arrayBuffer) return arrayBuffer + } + + return null } const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => { @@ -347,13 +403,10 @@ export const SignPage = () => { } const handleDecrypt = async () => { - if (!selectedFile || !encryptionKey) return + if (!selectedFile) return setIsLoading(true) - const arrayBuffer = await decrypt( - selectedFile, - decodeURIComponent(encryptionKey) - ) + const arrayBuffer = await decrypt(selectedFile) if (!arrayBuffer) return @@ -366,26 +419,15 @@ export const SignPage = () => { setIsLoading(true) setLoadingSpinnerDesc('parsing hashes.json file') - const hashesFileContent = await readContentOfZipEntry( - zip, - 'hashes.json', - 'string' - ) + const hashesFileContent = await readHashesFile() + if (!hashesFileContent) return if (!hashesFileContent) { setIsLoading(false) return } - let hashes = await parseJson(hashesFileContent).catch((err) => { - console.log('err in parsing the content of hashes.json :>> ', err) - toast.error( - err.message || 'error occurred in parsing the content of hashes.json' - ) - setIsLoading(false) - return null - }) - + const hashes = await parseHashes(hashesFileContent) if (!hashes) return setLoadingSpinnerDesc('Generating hashes for files') @@ -395,37 +437,95 @@ export const SignPage = () => { const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!)) if (!prevSig) return - const signedEvent = await signEventForMetaFile( - JSON.stringify({ - prevSig - }), - nostrController, - setIsLoading - ) - + const signedEvent = await signEventForMeta(prevSig) if (!signedEvent) return - const metaCopy = _.cloneDeep(meta) + const updatedMeta = updateMetaSignatures(meta, signedEvent) - metaCopy.docSignatures = { - ...metaCopy.docSignatures, - [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) - } - - const stringifiedMeta = JSON.stringify(metaCopy, null, 2) + const stringifiedMeta = JSON.stringify(updatedMeta, null, 2) zip.file('meta.json', stringifiedMeta) const metaHash = await getHash(stringifiedMeta) if (!metaHash) return - hashes = { + const updatedHashes = updateHashes(hashes, metaHash) + zip.file('hashes.json', JSON.stringify(updatedHashes, null, 2)) + + const arrayBuffer = await generateZipArrayBuffer(zip) + if (!arrayBuffer) return + + const key = await generateEncryptionKey() + + setLoadingSpinnerDesc('Encrypting zip file') + const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) + + if (await isOnline()) { + await handleOnlineFlow(encryptedArrayBuffer, key) + } else { + handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false)) + } + } + + // Read the content of the hashes.json file + const readHashesFile = async (): Promise => { + return await readContentOfZipEntry(zip!, 'hashes.json', 'string').catch( + (err) => { + console.log('Error reading hashes.json file:', err) + setIsLoading(false) + return null + } + ) + } + + // Parse the JSON content of the hashes file + const parseHashes = async ( + hashesFileContent: string + ): Promise | null> => { + return await parseJson>(hashesFileContent).catch( + (err) => { + console.log('Error parsing hashes.json content:', err) + toast.error(err.message || 'Error parsing hashes.json content') + setIsLoading(false) + return null + } + ) + } + + // Sign the event for the meta file + const signEventForMeta = async (prevSig: string) => { + return await signEventForMetaFile( + JSON.stringify({ prevSig }), + nostrController, + setIsLoading + ) + } + + // Update the meta signatures + const updateMetaSignatures = (meta: Meta, signedEvent: SignedEvent): Meta => { + const metaCopy = _.cloneDeep(meta) + metaCopy.docSignatures = { + ...metaCopy.docSignatures, + [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) + } + return metaCopy + } + + // Update the hashes with the new meta hash + const updateHashes = ( + hashes: Record, + metaHash: string + ): Record => { + return { ...hashes, [usersPubkey!]: metaHash } + } - zip.file('hashes.json', JSON.stringify(hashes, null, 2)) - - const arrayBuffer = await zip + // Generate the zip array buffer + const generateZipArrayBuffer = async ( + zip: JSZip + ): Promise => { + return await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', @@ -434,94 +534,194 @@ export const SignPage = () => { } }) .catch((err) => { - console.log('err in zip:>> ', err) + console.log('Error generating zip file:', err) setIsLoading(false) - toast.error(err.message || 'Error occurred in generating zip file') + toast.error(err.message || 'Error generating zip file') return null }) + } - if (!arrayBuffer) return - - const key = await generateEncryptionKey() - - setLoadingSpinnerDesc('Encrypting zip file') - const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) - + // create final zip file + const createFinalZipFile = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ): Promise => { + // Get the current timestamp in seconds + const unixNow = Math.floor(Date.now() / 1000) const blob = new Blob([encryptedArrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed.sigit`, { + type: 'application/sigit' + }) - if (await isOnline()) { - setLoadingSpinnerDesc('Uploading zip file to file storage.') - const fileUrl = await uploadToFileStorage(blob, nostrController) - .then((url) => { - toast.success('zip file uploaded to file storage') - return url - }) - .catch((err) => { - console.log('err in upload:>> ', err) - setIsLoading(false) - toast.error(err.message || 'Error occurred in uploading zip file') - return null - }) + const isLastSigner = checkIsLastSigner(signers) - if (!fileUrl) return + const userSet = new Set() - // check if the current user is the last signer - const usersNpub = hexToNpub(usersPubkey!) - const lastSignerIndex = signers.length - 1 - const signerIndex = signers.indexOf(usersNpub) - const isLastSigner = signerIndex === lastSignerIndex - - // if current user is the last signer, then send DMs to all signers and viewers - if (isLastSigner) { - const userSet = new Set<`npub1${string}`>() - - if (submittedBy) { - userSet.add(hexToNpub(submittedBy)) - } - - signers.forEach((signer) => { - userSet.add(signer) - }) - - viewers.forEach((viewer) => { - userSet.add(viewer) - }) - - const users = Array.from(userSet) - - for (const user of users) { - // todo: execute in parallel - await sendDM( - fileUrl, - key, - npubToHex(user)!, - nostrController, - false, - setAuthUrl - ) - } - } else { - const nextSigner = signers[signerIndex + 1] - await sendDM( - fileUrl, - key, - npubToHex(nextSigner)!, - nostrController, - true, - setAuthUrl - ) + if (isLastSigner) { + if (submittedBy) { + userSet.add(submittedBy) } - setIsLoading(false) + signers.forEach((signer) => { + userSet.add(npubToHex(signer)!) + }) - // update search params with updated file url and encryption key - setSearchParams({ - file: fileUrl, - key: key + viewers.forEach((viewer) => { + userSet.add(npubToHex(viewer)!) }) } else { - handleDecryptedArrayBuffer(arrayBuffer).finally(() => setIsLoading(false)) + 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 + + const finalZipFile = new File( + [new Blob([arraybuffer])], + `${unixNow}.sigit.zip`, + { + type: 'application/zip' + } + ) + + return finalZipFile + } + + // Handle errors during zip file generation + const handleZipError = (err: any) => { + console.log('Error in zip:>> ', err) + setIsLoading(false) + toast.error(err.message || 'Error occurred in generating zip file') + return null + } + + // Handle the online flow: upload file and send DMs + const handleOnlineFlow = async ( + encryptedArrayBuffer: ArrayBuffer, + encryptionKey: string + ) => { + const unixNow = Math.floor(Date.now() / 1000) + const blob = new Blob([encryptedArrayBuffer]) + // Create a File object with the Blob data + const file = new File([blob], `compressed-${unixNow}.sigit`, { + type: 'application/sigit' + }) + + const fileUrl = await uploadFile(file) + if (!fileUrl) return + + const isLastSigner = checkIsLastSigner(signers) + + if (isLastSigner) { + await sendDMToAllUsers(fileUrl, encryptionKey) + } else { + await sendDMToNextSigner(fileUrl, encryptionKey) + } + + // update search params with updated file url and encryption key + setSearchParams({ + file: fileUrl, + key: encryptionKey + }) + + setIsLoading(false) + } + + // Handle errors during file upload + const handleUploadError = (err: any) => { + console.log('Error in upload:>> ', err) + setIsLoading(false) + toast.error(err.message || 'Error occurred in uploading file') + return null + } + + // Upload the file to file storage + const uploadFile = async (file: File): Promise => { + setIsLoading(true) + setLoadingSpinnerDesc('Uploading sigit file to file storage.') + + const fileUrl = await uploadToFileStorage(file, nostrController) + .then((url) => { + toast.success('Sigit uploaded to file storage') + return url + }) + .catch(handleUploadError) + + return fileUrl + } + + // 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 + } + + // Send DM to all users (signers and viewers) + const sendDMToAllUsers = async (fileUrl: string, encryptionKey: string) => { + const userSet = new Set<`npub1${string}`>() + + if (submittedBy) { + userSet.add(hexToNpub(submittedBy)) + } + + signers.forEach((signer) => { + userSet.add(signer) + }) + + viewers.forEach((viewer) => { + userSet.add(viewer) + }) + + const users = Array.from(userSet) + + for (const user of users) { + await sendDM( + fileUrl, + encryptionKey, + npubToHex(user)!, + nostrController, + false, + setAuthUrl + ) + } + } + + // Send DM to the next signer + const sendDMToNextSigner = async (fileUrl: string, encryptionKey: string) => { + const usersNpub = hexToNpub(usersPubkey!) + const signerIndex = signers.indexOf(usersNpub) + const nextSigner = signers[signerIndex + 1] + await sendDM( + fileUrl, + encryptionKey, + npubToHex(nextSigner)!, + nostrController, + true, + setAuthUrl + ) } const handleExport = async () => { @@ -581,7 +781,8 @@ export const SignPage = () => { if (!arrayBuffer) return const blob = new Blob([arrayBuffer]) - saveAs(blob, 'exported.zip') + const unixNow = Math.floor(Date.now() / 1000) + saveAs(blob, `exported-${unixNow}.sigit.zip`) setIsLoading(false) @@ -612,11 +813,12 @@ export const SignPage = () => { setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) - const blob = new Blob([encryptedArrayBuffer]) - saveAs(blob, 'exported.sigit') - setTextToCopy(key) - setOpenCopyModel(true) + const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) + + if (!finalZipFile) return + const unixNow = Math.floor(Date.now() / 1000) + saveAs(finalZipFile, `exported-${unixNow}.sigit.zip`) } /** @@ -707,22 +909,13 @@ export const SignPage = () => { setSelectedFile(value)} /> - - {selectedFile && ( - setEncryptionKey(e.target.value)} - /> - )} - {selectedFile && encryptionKey && ( + {selectedFile && ( - - )} + {/* todo: In offline mode export sigit is not visible after last signer has signed*/} + {isSignerOrCreator && ( + + + + )} )} - setOpenCopyModel(false)} - title="Decryption key for Sigit file" - textToCopy={textToCopy} - /> ) } - -type DisplayMetaProps = { - meta: Meta - zip: JSZip - submittedBy: string - signers: `npub1${string}`[] - viewers: `npub1${string}`[] - creatorFileHashes: { [key: string]: string } - currentFileHashes: { [key: string]: string | null } - signedBy: `npub1${string}`[] - nextSigner?: string - getPrevSignersSig: (usersNpub: string) => string | null -} - -const DisplayMeta = ({ - meta, - zip, - submittedBy, - signers, - viewers, - creatorFileHashes, - currentFileHashes, - signedBy, - nextSigner, - getPrevSignersSig -}: DisplayMetaProps) => { - const theme = useTheme() - - const textColor = theme.palette.getContrastText( - theme.palette.background.paper - ) - - const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) - const [users, setUsers] = useState([]) - - useEffect(() => { - signers.forEach((signer) => { - const hexKey = npubToHex(signer) - setUsers((prev) => { - if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev - - return [ - ...prev, - { - pubkey: hexKey!, - role: UserRole.signer - } - ] - }) - }) - - viewers.forEach((viewer) => { - const hexKey = npubToHex(viewer) - setUsers((prev) => { - if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev - - return [ - ...prev, - { - pubkey: hexKey!, - role: UserRole.viewer - } - ] - }) - }) - }, [signers, viewers]) - - useEffect(() => { - const metadataController = new MetadataController() - - const hexKeys: string[] = [ - npubToHex(submittedBy)!, - ...users.map((user) => user.pubkey) - ] - - hexKeys.forEach((key) => { - if (!(key in metadata)) { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [key]: metadataContent - })) - } - - metadataController.on(key, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } - }) - - metadataController - .findMetadata(key) - .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) - }) - .catch((err) => { - console.error(`error occurred in finding metadata for: ${key}`, err) - }) - } - }) - }, [users, submittedBy]) - - const downloadFile = async (filename: string) => { - const arrayBuffer = await readContentOfZipEntry( - zip, - `files/${filename}`, - 'arraybuffer' - ) - if (!arrayBuffer) return - - const blob = new Blob([arrayBuffer]) - saveAs(blob, filename) - } - - return ( - Meta Info - } - > - - - Submitted By - - {(function () { - const profile = metadata[submittedBy] - return ( - - ) - })()} - - - - Files - - - {Object.entries(currentFileHashes).map(([filename, hash], index) => { - const isValidHash = creatorFileHashes[filename] === hash - - return ( - - - downloadFile(filename)}> - - - - - {filename} - - {isValidHash && ( - - - - )} - {!isValidHash && ( - - - - )} - - ) - })} - - - - - - - User - Role - Signed Status - - - - {users.map((user) => ( - - ))} - -
-
-
- ) -} - -enum PrevSignatureValidationEnum { - Pending, - Valid, - Invalid -} - -enum UserStatus { - Viewer = 'Viewer', - Awaiting = 'Awaiting Signature', - Signed = 'Signed', - Pending = 'Pending' -} - -type DisplayUserProps = { - meta: Meta - user: User - metadata: { [key: string]: ProfileMetadata } - signedBy: `npub1${string}`[] - nextSigner?: string - getPrevSignersSig: (usersNpub: string) => string | null -} - -const DisplayUser = ({ - meta, - user, - metadata, - signedBy, - nextSigner, - getPrevSignersSig -}: DisplayUserProps) => { - const theme = useTheme() - - const userMeta = metadata[user.pubkey] - const [userStatus, setUserStatus] = useState(UserStatus.Pending) - const [prevSignatureStatus, setPreviousSignatureStatus] = - useState(PrevSignatureValidationEnum.Pending) - - useEffect(() => { - if (user.role === UserRole.viewer) { - setUserStatus(UserStatus.Viewer) - return - } - - // check if user has signed the document - const usersNpub = hexToNpub(user.pubkey) - if (signedBy.includes(usersNpub)) { - setUserStatus(UserStatus.Signed) - return - } - - // check if user is the next signer - if (user.pubkey === nextSigner) { - setUserStatus(UserStatus.Awaiting) - return - } - }, [user, nextSigner, signedBy]) - - useEffect(() => { - const validatePrevSignature = async () => { - const handleNullCase = () => { - setPreviousSignatureStatus(PrevSignatureValidationEnum.Invalid) - return - } - - // get previous signers sig from the content of current signers signed event - const npub = hexToNpub(user.pubkey) - const signedEvent = await parseJson( - meta.docSignatures[npub] - ).catch((err) => { - console.log(`err in parsing the singed event for ${npub}:>> `, err) - toast.error( - err.message || - 'error occurred in parsing the signed event signature event' - ) - return null - }) - - if (!signedEvent) return handleNullCase() - - // now that we have signed event of current signer, we'll extract prevSig from its content - const parsedContent = await parseJson( - signedEvent.content - ).catch((err) => { - console.log( - `an error occurred in parsing the content of signedEvent of ${npub}`, - err - ) - toast.error( - err.message || - `an error occurred in parsing the content of signedEvent of ${npub}` - ) - return null - }) - - if (!parsedContent) return handleNullCase() - - const prevSignersSignature = getPrevSignersSig(npub) - - if (!prevSignersSignature) return handleNullCase() - - setPreviousSignatureStatus( - parsedContent.prevSig === prevSignersSignature - ? PrevSignatureValidationEnum.Valid - : PrevSignatureValidationEnum.Invalid - ) - } - - if (userStatus === UserStatus.Signed) { - validatePrevSignature() - } - }, [userStatus, meta.docSignatures, user.pubkey, getPrevSignersSig]) - - return ( - - - - - {user.role} - - - {userStatus} - {userStatus === UserStatus.Signed && ( - <> - {prevSignatureStatus === PrevSignatureValidationEnum.Valid && ( - - - - )} - {prevSignatureStatus === PrevSignatureValidationEnum.Invalid && ( - - - - )} - - )} - {userStatus === UserStatus.Awaiting && ( - - - - )} - - - - ) -} diff --git a/src/pages/sign/internal/displayMeta.tsx b/src/pages/sign/internal/displayMeta.tsx new file mode 100644 index 0000000..e041e37 --- /dev/null +++ b/src/pages/sign/internal/displayMeta.tsx @@ -0,0 +1,426 @@ +import JSZip from 'jszip' +import { + Meta, + ProfileMetadata, + SignedEventContent, + User, + UserRole +} from '../../../types' +import { + Box, + IconButton, + List, + ListItem, + ListSubheader, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, + useTheme +} from '@mui/material' +import { + Download, + CheckCircle, + Cancel, + HourglassTop +} from '@mui/icons-material' +import saveAs from 'file-saver' +import { kinds, Event } from 'nostr-tools' +import { useState, useEffect } from 'react' +import { toast } from 'react-toastify' +import { UserComponent } from '../../../components/username' +import { MetadataController } from '../../../controllers' +import { + npubToHex, + readContentOfZipEntry, + shorten, + hexToNpub, + parseJson +} from '../../../utils' +import styles from '../style.module.scss' + +type DisplayMetaProps = { + meta: Meta + zip: JSZip + submittedBy: string + signers: `npub1${string}`[] + viewers: `npub1${string}`[] + creatorFileHashes: { [key: string]: string } + currentFileHashes: { [key: string]: string | null } + signedBy: `npub1${string}`[] + nextSigner?: string + getPrevSignersSig: (usersNpub: string) => string | null +} + +export const DisplayMeta = ({ + meta, + zip, + submittedBy, + signers, + viewers, + creatorFileHashes, + currentFileHashes, + signedBy, + nextSigner, + getPrevSignersSig +}: DisplayMetaProps) => { + const theme = useTheme() + + const textColor = theme.palette.getContrastText( + theme.palette.background.paper + ) + + const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( + {} + ) + const [users, setUsers] = useState([]) + + useEffect(() => { + signers.forEach((signer) => { + const hexKey = npubToHex(signer) + setUsers((prev) => { + if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev + + return [ + ...prev, + { + pubkey: hexKey!, + role: UserRole.signer + } + ] + }) + }) + + viewers.forEach((viewer) => { + const hexKey = npubToHex(viewer) + setUsers((prev) => { + if (prev.findIndex((user) => user.pubkey === hexKey) !== -1) return prev + + return [ + ...prev, + { + pubkey: hexKey!, + role: UserRole.viewer + } + ] + }) + }) + }, [signers, viewers]) + + useEffect(() => { + const metadataController = new MetadataController() + + const hexKeys: string[] = [ + npubToHex(submittedBy)!, + ...users.map((user) => user.pubkey) + ] + + hexKeys.forEach((key) => { + if (!(key in metadata)) { + const handleMetadataEvent = (event: Event) => { + const metadataContent = + metadataController.extractProfileMetadataContent(event) + + if (metadataContent) + setMetadata((prev) => ({ + ...prev, + [key]: metadataContent + })) + } + + metadataController.on(key, (kind: number, event: Event) => { + if (kind === kinds.Metadata) { + handleMetadataEvent(event) + } + }) + + metadataController + .findMetadata(key) + .then((metadataEvent) => { + if (metadataEvent) handleMetadataEvent(metadataEvent) + }) + .catch((err) => { + console.error(`error occurred in finding metadata for: ${key}`, err) + }) + } + }) + }, [users, submittedBy]) + + const downloadFile = async (filename: string) => { + const arrayBuffer = await readContentOfZipEntry( + zip, + `files/${filename}`, + 'arraybuffer' + ) + if (!arrayBuffer) return + + const blob = new Blob([arrayBuffer]) + saveAs(blob, filename) + } + + return ( + Meta Info + } + > + + + Submitted By + + {(function () { + const profile = metadata[submittedBy] + return ( + + ) + })()} + + + + Files + + + {Object.entries(currentFileHashes).map(([filename, hash], index) => { + const isValidHash = creatorFileHashes[filename] === hash + + return ( + + + downloadFile(filename)}> + + + + + {filename} + + {isValidHash && ( + + + + )} + {!isValidHash && ( + + + + )} + + ) + })} + + + + + + + User + Role + Signed Status + + + + {users.map((user) => ( + + ))} + +
+
+
+ ) +} + +enum PrevSignatureValidationEnum { + Pending, + Valid, + Invalid +} + +enum UserStatus { + Viewer = 'Viewer', + Awaiting = 'Awaiting Signature', + Signed = 'Signed', + Pending = 'Pending' +} + +type DisplayUserProps = { + meta: Meta + user: User + metadata: { [key: string]: ProfileMetadata } + signedBy: `npub1${string}`[] + nextSigner?: string + getPrevSignersSig: (usersNpub: string) => string | null +} + +const DisplayUser = ({ + meta, + user, + metadata, + signedBy, + nextSigner, + getPrevSignersSig +}: DisplayUserProps) => { + const theme = useTheme() + + const userMeta = metadata[user.pubkey] + const [userStatus, setUserStatus] = useState(UserStatus.Pending) + const [prevSignatureStatus, setPreviousSignatureStatus] = + useState(PrevSignatureValidationEnum.Pending) + + useEffect(() => { + if (user.role === UserRole.viewer) { + setUserStatus(UserStatus.Viewer) + return + } + + // check if user has signed the document + const usersNpub = hexToNpub(user.pubkey) + if (signedBy.includes(usersNpub)) { + setUserStatus(UserStatus.Signed) + return + } + + // check if user is the next signer + if (user.pubkey === nextSigner) { + setUserStatus(UserStatus.Awaiting) + return + } + }, [user, nextSigner, signedBy]) + + useEffect(() => { + const validatePrevSignature = async () => { + const handleNullCase = () => { + setPreviousSignatureStatus(PrevSignatureValidationEnum.Invalid) + return + } + + // get previous signers sig from the content of current signers signed event + const npub = hexToNpub(user.pubkey) + const signedEvent = await parseJson( + meta.docSignatures[npub] + ).catch((err) => { + console.log(`err in parsing the singed event for ${npub}:>> `, err) + toast.error( + err.message || + 'error occurred in parsing the signed event signature event' + ) + return null + }) + + if (!signedEvent) return handleNullCase() + + // now that we have signed event of current signer, we'll extract prevSig from its content + const parsedContent = await parseJson( + signedEvent.content + ).catch((err) => { + console.log( + `an error occurred in parsing the content of signedEvent of ${npub}`, + err + ) + toast.error( + err.message || + `an error occurred in parsing the content of signedEvent of ${npub}` + ) + return null + }) + + if (!parsedContent) return handleNullCase() + + const prevSignersSignature = getPrevSignersSig(npub) + + if (!prevSignersSignature) return handleNullCase() + + setPreviousSignatureStatus( + parsedContent.prevSig === prevSignersSignature + ? PrevSignatureValidationEnum.Valid + : PrevSignatureValidationEnum.Invalid + ) + } + + if (userStatus === UserStatus.Signed) { + validatePrevSignature() + } + }, [userStatus, meta.docSignatures, user.pubkey, getPrevSignersSig]) + + return ( + + + + + {user.role} + + + {userStatus} + {userStatus === UserStatus.Signed && ( + <> + {prevSignatureStatus === PrevSignatureValidationEnum.Valid && ( + + + + )} + {prevSignatureStatus === PrevSignatureValidationEnum.Invalid && ( + + + + )} + + )} + {userStatus === UserStatus.Awaiting && ( + + + + )} + + + + ) +} diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index a8e0d1f..53aea4a 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -32,14 +32,17 @@ import { } from '../../utils' import styles from './style.module.scss' import { Cancel, CheckCircle } from '@mui/icons-material' +import { useLocation } from 'react-router-dom' export const VerifyPage = () => { const theme = useTheme() - const textColor = theme.palette.getContrastText( theme.palette.background.paper ) + const location = useLocation() + const { uploadedZip } = location.state || {} + const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') @@ -62,6 +65,12 @@ export const VerifyPage = () => { {} ) + useEffect(() => { + if (uploadedZip) { + setSelectedFile(uploadedZip) + } + }, [uploadedZip]) + useEffect(() => { if (zip) { const generateCurrentFileHashes = async () => { @@ -364,7 +373,7 @@ export const VerifyPage = () => { onChange={(value) => setSelectedFile(value)} InputProps={{ inputProps: { - accept: '.zip' + accept: '.sigit.zip' } }} /> diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 03b2fa6..fc53dff 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,5 +1,10 @@ import axios from 'axios' -import { EventTemplate } from 'nostr-tools' +import { + EventTemplate, + generateSecretKey, + getPublicKey, + nip04 +} from 'nostr-tools' import { MetadataController, NostrController } from '../controllers' import { toast } from 'react-toastify' import { appPrivateRoutes } from '../routes' @@ -11,17 +16,12 @@ import { appPrivateRoutes } from '../routes' * @returns The URL of the uploaded file. */ export const uploadToFileStorage = async ( - blob: Blob, + file: File, nostrController: NostrController ) => { // Get the current timestamp in seconds const unixNow = Math.floor(Date.now() / 1000) - // Create a File object with the Blob data - const file = new File([blob], `compressed-${unixNow}.sigit`, { - type: 'application/sigit' - }) - // Define event metadata for authorization const event: EventTemplate = { kind: 24242, @@ -205,3 +205,52 @@ export const signEventForMetaFile = async ( return signedEvent // Return the signed event } + +/** + * Generates the content for keys.json file. + * + * @param users - An array of public keys. + * @param key - The key that will be encrypted for each user. + * @returns A promise that resolves to a JSON string containing the sender's public key and encrypted keys, or null if an error occurs. + */ +export const generateKeysFile = async ( + users: string[], + key: string +): Promise => { + // Generate a random private key to act as the sender + const privateKey = generateSecretKey() + + // Calculate the required length to be a multiple of 10 + const requiredLength = Math.ceil(users.length / 10) * 10 + const additionalKeysCount = requiredLength - users.length + + if (additionalKeysCount > 0) { + // generate random public keys to make the keys array multiple of 10 + const additionalPubkeys = Array.from({ length: additionalKeysCount }, () => + getPublicKey(generateSecretKey()) + ) + + users.push(...additionalPubkeys) + } + + // Encrypt the key for each user's public key + const promises = users.map((pubkey) => nip04.encrypt(privateKey, pubkey, key)) + + // Wait for all encryption promises to resolve + const keys = await Promise.all(promises).catch((err) => { + console.log('Error while generating keys :>> ', err) + toast.error(err.message || 'An error occurred while generating key') + return null + }) + + // If any encryption promise failed, return null + if (!keys) return null + + try { + // Return a JSON string containing the sender's public key and encrypted keys + return JSON.stringify({ sender: getPublicKey(privateKey), keys }) + } catch (error) { + // Return null if an error occurs during JSON stringification + return null + } +}