import { Box, Button, Typography } from '@mui/material' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' import { useEffect, useRef, useState } from 'react' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' import { DocSignatureEvent, Meta, SignedEvent, OpenTimestamp, OpenTimestampUpgradeVerifyResponse } from '../../types' import { decryptArrayBuffer, getHash, hexToNpub, unixNow, parseJson, readContentOfZipEntry, signEventForMetaFile, getCurrentUserFiles, updateUsersAppData, npubToHex, sendNotification, generateEncryptionKey, encryptArrayBuffer, generateKeysFile, ARRAY_BUFFER, DEFLATE, uploadMetaToFileStorage } 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 } from '../../hooks' import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { UsersDetails } from '../../components/UsersDetails.tsx' import FileList from '../../components/FileList' import { CurrentUserFile } from '../../types/file.ts' import { Mark } from '../../types/mark.ts' import React from 'react' import { convertToSigitFile, getZipWithFiles, SigitFile } from '../../utils/file.ts' import { FileDivider } from '../../components/FileDivider.tsx' import { ExtensionFileBox } from '../../components/ExtensionFileBox.tsx' import { useScale } from '../../hooks/useScale.tsx' import { faCircleInfo, faFile, faFileDownload } from '@fortawesome/free-solid-svg-icons' import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts' import _ from 'lodash' import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx' interface PdfViewProps { files: CurrentUserFile[] currentFile: CurrentUserFile | null parsedSignatureEvents: { [signer: `npub1${string}`]: DocSignatureEvent } } const SlimPdfView = ({ files, currentFile, parsedSignatureEvents }: PdfViewProps) => { const pdfRefs = useRef<(HTMLDivElement | null)[]>([]) const { from } = useScale() useEffect(() => { if (currentFile !== null && !!pdfRefs.current[currentFile.id]) { pdfRefs.current[currentFile.id]?.scrollIntoView({ behavior: 'smooth' }) } }, [currentFile]) return (
{files.length > 0 ? ( files.map((currentUserFile, i) => { const { hash, file, id } = currentUserFile const signatureEvents = Object.keys(parsedSignatureEvents) if (!hash) return return (
(pdfRefs.current[id] = el)} className="file-wrapper" > {file.isPdf && file.pages?.map((page, i) => { const marks: Mark[] = [] signatureEvents.forEach((e) => { const m = parsedSignatureEvents[ e as `npub1${string}` ].parsedContent?.marks.filter( (m) => m.pdfFileHash == hash && m.location.page == i ) if (m) { marks.push(...m) } }) return (
{`page {marks.map((m) => { return (
) })}
) })} {file.isImage && ( {file.name} )} {!(file.isPdf || file.isImage) && ( )}
{i < files.length - 1 && }
) }) ) : ( )}
) } export const VerifyPage = () => { const location = useLocation() const params = useParams() const usersAppData = useAppSelector((state) => state.userAppData) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) const nostrController = NostrController.getInstance() const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') /** * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json * meta will be received in navigation from create & home page in online mode */ let metaInNavState = location?.state?.meta || undefined const { uploadedZip } = location.state || {} const [selectedFile, setSelectedFile] = useState(null) /** * If `userAppData` is present it means user is logged in and we can extract list of `sigits` from the store. * If ID is present in the URL we search in the `sigits` list * Otherwise sigit is set from the `location.state.meta` */ if (usersAppData) { const sigitCreateId = params.id if (sigitCreateId) { const sigit = usersAppData.sigits[sigitCreateId] if (sigit) { metaInNavState = sigit } } } useEffect(() => { if (uploadedZip) { setSelectedFile(uploadedZip) } }, [uploadedZip]) const [meta, setMeta] = useState(metaInNavState) const { submittedBy, zipUrls, encryptionKey, signers, viewers, fileHashes, parsedSignatureEvents, timestamps } = useSigitMeta(meta) const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [currentFile, setCurrentFile] = useState(null) const [currentFileHashes, setCurrentFileHashes] = useState<{ [key: string]: string | null }>({}) const signTimestampEvent = async (signerContent: { timestamps: OpenTimestamp[] }): Promise => { return await signEventForMetaFile( JSON.stringify(signerContent), nostrController, setIsLoading ) } useEffect(() => { if (Object.entries(files).length > 0) { const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes) setCurrentFile(tmp[0]) } }, [currentFileHashes, fileHashes, files]) useEffect(() => { if ( timestamps && timestamps.length > 0 && usersPubkey && submittedBy && parsedSignatureEvents ) { if (timestamps.every((t) => !!t.verification)) { return } const upgradeT = async (timestamps: OpenTimestamp[]) => { try { setLoadingSpinnerDesc('Upgrading your timestamps.') const findCreatorTimestamp = (timestamps: OpenTimestamp[]) => { if (usersPubkey === submittedBy) { return timestamps[0] } } const findSignerTimestamp = (timestamps: OpenTimestamp[]) => { const parsedEvent = parsedSignatureEvents[hexToNpub(usersPubkey)] if (parsedEvent?.id) { return timestamps.find((t) => t.nostrId === parsedEvent.id) } } /** * Checks if timestamp verification has been achieved for the first time. * Note that the upgrade flag is separate from verification. It is possible for a timestamp * to not be upgraded, but to be verified for the first time. * @param upgradedTimestamp * @param timestamps */ const isNewlyVerified = ( upgradedTimestamp: OpenTimestampUpgradeVerifyResponse, timestamps: OpenTimestamp[] ) => { if (!upgradedTimestamp.verified) return false const oldT = timestamps.find( (t) => t.nostrId === upgradedTimestamp.timestamp.nostrId ) if (!oldT) return false if (!oldT.verification && upgradedTimestamp.verified) return true } const userTimestamps: OpenTimestamp[] = [] const creatorTimestamp = findCreatorTimestamp(timestamps) if (creatorTimestamp) { userTimestamps.push(creatorTimestamp) } const signerTimestamp = findSignerTimestamp(timestamps) if (signerTimestamp) { userTimestamps.push(signerTimestamp) } if (userTimestamps.every((t) => !!t.verification)) { return } const upgradedUserTimestamps = await Promise.all( userTimestamps.map(upgradeAndVerifyTimestamp) ) const upgradedTimestamps = upgradedUserTimestamps .filter((t) => t.upgraded || isNewlyVerified(t, userTimestamps)) .map((t) => { const timestamp: OpenTimestamp = { ...t.timestamp } if (t.verified) { timestamp.verification = t.verification } return timestamp }) if (upgradedTimestamps.length === 0) { return } setLoadingSpinnerDesc('Signing a timestamp upgrade event.') const signedEvent = await signTimestampEvent({ timestamps: upgradedTimestamps }) if (!signedEvent) return const finalTimestamps = timestamps.map((t) => { const upgraded = upgradedTimestamps.find( (tu) => tu.nostrId === t.nostrId ) if (upgraded) { return { ...upgraded, signature: JSON.stringify(signedEvent, null, 2) } } return t }) const updatedMeta = _.cloneDeep(meta) updatedMeta.timestamps = [...finalTimestamps] updatedMeta.modifiedAt = unixNow() const updatedEvent = await updateUsersAppData(updatedMeta) if (!updatedEvent) return const metaUrl = await uploadMetaToFileStorage( updatedMeta, encryptionKey ) const userSet = new Set<`npub1${string}`>() signers.forEach((signer) => { if (signer !== usersPubkey) { userSet.add(signer) } }) viewers.forEach((viewer) => { userSet.add(viewer) }) const users = Array.from(userSet) const promises = users.map((user) => sendNotification(npubToHex(user)!, { metaUrls: metaUrl, keys: meta.keys! }) ) await Promise.all(promises) toast.success('Timestamp updates have been sent successfully.') setMeta(meta) } catch (err) { console.error(err) toast.error( 'There was an error upgrading or verifying your timestamps!' ) } } upgradeT(timestamps) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [timestamps, submittedBy, parsedSignatureEvents]) useEffect(() => { if (metaInNavState && encryptionKey) { const processSigit = async () => { setIsLoading(true) // We have multiple zipUrls, we should fetch one by one and take the first one which successfully decrypts // If file is altered decrytption will fail setLoadingSpinnerDesc('Fetching file from file server') for (let i = 0; i < zipUrls.length; i++) { const zipUrl = '' const isLastZipUrl = i === zipUrls.length - 1 try { // Fetch zip data const res = await axios.get(zipUrl, { responseType: 'arraybuffer' }) // Prepare file from response const fileName = zipUrl.split('/').pop() const file = new File([res.data], fileName!) const encryptedArrayBuffer = await file.arrayBuffer() // Decrypt the array buffer const arrayBuffer = await decryptArrayBuffer( encryptedArrayBuffer, encryptionKey ).catch((err) => { console.error('Error in decryption:>> ', err) toast.error( err.message || 'An error occurred in decrypting file.' ) return null // Continue iteration for next zipUrl }) if (!arrayBuffer) { if (!isLastZipUrl) continue // Skip to next zipUrl if decryption fails break // If last zipUrl break out of loop } // Load zip archive const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => { console.error('Error in loading zip file :>> ', err) toast.error( err.message || 'An error occurred in loading zip file.' ) return null // Skip to next zipUrl }) if (!zip) { if (!isLastZipUrl) continue // Skip to next zipUrl break // If last zipUrl break out of loop } const files: { [fileName: string]: SigitFile } = {} const fileHashes: { [key: string]: string | null } = {} const fileNames = Object.values(zip.files).map( (entry) => entry.name ) // Generate hashes for all entries in the files folder of zipArchive for (const entryFileName of fileNames) { const entryArrayBuffer = await readContentOfZipEntry( zip, entryFileName, 'arraybuffer' ) if (entryArrayBuffer) { files[entryFileName] = await convertToSigitFile( entryArrayBuffer, entryFileName ) const hash = await getHash(entryArrayBuffer) if (hash) { fileHashes[entryFileName.replace(/^files\//, '')] = hash } } else { fileHashes[entryFileName.replace(/^files\//, '')] = null } } setCurrentFileHashes(fileHashes) setFiles(files) setIsLoading(false) } catch (err) { const message = `error occurred in getting file from ${zipUrl}` console.error(message, err) if (err instanceof Error) toast.error(err.message) else toast.error(message) } finally { setIsLoading(false) } } } processSigit() } }, [encryptionKey, metaInNavState, zipUrls]) const handleVerify = async () => { if (!selectedFile) return setIsLoading(true) const 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 const files: { [filename: string]: SigitFile } = {} const fileHashes: { [key: string]: string | null } = {} const fileNames = Object.values(zip.files) .filter((entry) => entry.name.startsWith('files/') && !entry.dir) .map((entry) => entry.name) // generate hashes for all entries in files folder of zipArchive // these hashes can be used to verify the originality of files for (const zipFilePath of fileNames) { const arrayBuffer = await readContentOfZipEntry( zip, zipFilePath, 'arraybuffer' ) const fileName = zipFilePath.replace(/^files\//, '') if (arrayBuffer) { files[fileName] = await convertToSigitFile(arrayBuffer, fileName) const hash = await getHash(arrayBuffer) if (hash) { fileHashes[fileName] = hash } } else { fileHashes[fileName] = null } } setFiles(files) setCurrentFileHashes(fileHashes) setLoadingSpinnerDesc('Parsing meta.json') const metaFileContent = await readContentOfZipEntry( zip, 'meta.json', 'string' ) if (!metaFileContent) { setIsLoading(false) return } const parsedMetaJson = await parseJson(metaFileContent).catch( (err) => { console.log('err in parsing the content of meta.json :>> ', err) toast.error( err.message || 'error occurred in parsing the content of meta.json' ) setIsLoading(false) return null } ) if (!parsedMetaJson) return setMeta(parsedMetaJson) setIsLoading(false) } // Handle errors during zip file generation const handleZipError = (err: unknown) => { console.log('Error in zip:>> ', err) setIsLoading(false) if (err instanceof Error) { toast.error(err.message || 'Error occurred in generating zip file') } return null } // Check if the current user is the last signer const checkIsLastSigner = (signers: string[]): boolean => { const usersNpub = hexToNpub(usersPubkey!) const lastSignerIndex = signers.length - 1 const signerIndex = signers.indexOf(usersNpub) return signerIndex === lastSignerIndex } // create final zip file const createFinalZipFile = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ): Promise => { // Get the current timestamp in seconds const blob = new Blob([encryptedArrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed.sigit`, { type: 'application/sigit' }) const isLastSigner = checkIsLastSigner(signers) const userSet = new Set() if (isLastSigner) { if (submittedBy) { userSet.add(submittedBy) } signers.forEach((signer) => { userSet.add(npubToHex(signer)!) }) viewers.forEach((viewer) => { userSet.add(npubToHex(viewer)!) }) } else { const usersNpub = hexToNpub(usersPubkey!) const signerIndex = signers.indexOf(usersNpub) const nextSigner = signers[signerIndex + 1] userSet.add(npubToHex(nextSigner)!) } const keysFileContent = await generateKeysFile( Array.from(userSet), encryptionKey ) if (!keysFileContent) return null const zip = new JSZip() zip.file(`compressed.sigit`, file) zip.file('keys.json', keysFileContent) const arraybuffer = await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) .catch(handleZipError) if (!arraybuffer) return null return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, { type: 'application/zip' }) } const handleExport = async () => { const arrayBuffer = await prepareZipExport() if (!arrayBuffer) return const blob = new Blob([arrayBuffer]) saveAs(blob, `exported-${unixNow()}.sigit.zip`) setIsLoading(false) } const handleEncryptedExport = async () => { const arrayBuffer = await prepareZipExport() if (!arrayBuffer) return const key = await generateEncryptionKey() setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key) const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) if (!finalZipFile) return saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`) setIsLoading(false) } const prepareZipExport = async (): Promise => { if (Object.entries(files).length === 0 || !meta || !usersPubkey) return Promise.resolve(null) const usersNpub = hexToNpub(usersPubkey) if ( !signers.includes(usersNpub) && !viewers.includes(usersNpub) && submittedBy !== usersNpub ) { return Promise.resolve(null) } setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') 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 updatedMeta = { ...meta, exportSignature } const stringifiedMeta = JSON.stringify(updatedMeta, null, 2) const zip = await getZipWithFiles(updatedMeta, 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) } return ( <> {isLoading && } {!meta && ( <> Select exported zip file setSelectedFile(value)} InputProps={{ inputProps: { accept: '.sigit.zip' } }} /> {selectedFile && ( )} )} {meta && ( ) } right={} leftIcon={faFileDownload} centerIcon={faFile} rightIcon={faCircleInfo} > )} ) }