import { Box, Button, Divider, Tooltip, Typography } from '@mui/material' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' import { CreateSignatureEventContent, Meta } from '../../types' import { decryptArrayBuffer, extractMarksFromSignedMeta, getHash, hexToNpub, unixNow, parseJson, readContentOfZipEntry, signEventForMetaFile, shorten, getCurrentUserFiles } from '../../utils' import styles from './style.module.scss' import { useLocation } from 'react-router-dom' import axios from 'axios' import { PdfFile } from '../../types/drawing.ts' import { addMarks, convertToPdfBlob, convertToPdfFile, groupMarksByPage, inPx } from '../../utils/pdf.ts' import { State } from '../../store/rootReducer.ts' import { useSelector } from 'react-redux' 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/index.tsx' import { UserAvatar } from '../../components/UserAvatar/index.tsx' import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx' import { TooltipChild } from '../../components/TooltipChild.tsx' import FileList from '../../components/FileList' import { CurrentUserFile } from '../../types/file.ts' interface PdfViewProps { files: CurrentUserFile[] currentFile: CurrentUserFile | null } const SlimPdfView = ({ files, currentFile }: PdfViewProps) => { const pdfRefs = useRef<(HTMLDivElement | null)[]>([]) useEffect(() => { if (currentFile !== null && !!pdfRefs.current[currentFile.id]) { pdfRefs.current[currentFile.id]?.scrollIntoView({ behavior: 'smooth', block: 'end' }) } }, [currentFile]) return ( <div className={styles.view}> {files.map((currentUserFile, i) => { const { hash, filename, pdfFile, id } = currentUserFile if (!hash) return return ( <> <div id={filename} ref={(el) => (pdfRefs.current[id] = el)} key={filename} className={styles.fileWrapper} > {pdfFile.pages.map((page, i) => { return ( <div className={styles.imageWrapper} key={i}> <img draggable="false" src={page.image} /> {page.drawnFields.map((f, i) => ( <div key={i} style={{ position: 'absolute', border: '1px dotted black', left: inPx(f.left), top: inPx(f.top), width: inPx(f.width), height: inPx(f.height) }} ></div> ))} </div> ) })} </div> {i < files.length - 1 && ( <Divider sx={{ fontSize: '12px', color: 'rgba(0,0,0,0.15)' }} > File Separator </Divider> )} </> ) })} </div> ) } export const VerifyPage = () => { const location = useLocation() /** * 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 */ const { uploadedZip, meta } = location.state || {} const { submittedBy, zipUrl, encryptionKey, signers, viewers, fileHashes } = useSigitMeta(meta) console.log('----------', meta) const profiles = useSigitProfiles([ ...(submittedBy ? [submittedBy] : []), ...signers, ...viewers ]) const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [selectedFile, setSelectedFile] = useState<File | null>(null) const [currentFileHashes, setCurrentFileHashes] = useState<{ [key: string]: string | null }>(fileHashes) const [files, setFiles] = useState<{ [filename: string]: PdfFile }>({}) const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null) useEffect(() => { if (Object.entries(files).length > 0) { const tmp = getCurrentUserFiles(files, fileHashes) setCurrentFile(tmp[0]) } }, [fileHashes, files]) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const nostrController = NostrController.getInstance() useEffect(() => { if (uploadedZip) { setSelectedFile(uploadedZip) } else if (meta && encryptionKey) { const processSigit = async () => { setIsLoading(true) setLoadingSpinnerDesc('Fetching file from file server') axios .get(zipUrl, { responseType: 'arraybuffer' }) .then(async (res) => { const fileName = zipUrl.split('/').pop() const file = new File([res.data], fileName!) const encryptedArrayBuffer = await file.arrayBuffer() const arrayBuffer = await decryptArrayBuffer( encryptedArrayBuffer, encryptionKey ).catch((err) => { console.log('err in decryption:>> ', err) toast.error( err.message || 'An error occurred in decrypting file.' ) return null }) if (arrayBuffer) { const zip = await JSZip.loadAsync(arrayBuffer).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]: PdfFile } = {} const fileHashes: { [key: string]: string | null } = {} const fileNames = Object.values(zip.files).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 fileName of fileNames) { const arrayBuffer = await readContentOfZipEntry( zip, fileName, 'arraybuffer' ) if (arrayBuffer) { files[fileName] = await convertToPdfFile( arrayBuffer, fileName! ) const hash = await getHash(arrayBuffer) if (hash) { fileHashes[fileName.replace(/^files\//, '')] = hash } } else { fileHashes[fileName.replace(/^files\//, '')] = null } } setCurrentFileHashes(fileHashes) setFiles(files) setIsLoading(false) } }) .catch((err) => { console.error(`error occurred in getting file from ${zipUrl}`, err) toast.error( err.message || `error occurred in getting file from ${zipUrl}` ) }) .finally(() => { setIsLoading(false) }) } processSigit() } }, [encryptionKey, meta, uploadedZip, zipUrl]) 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 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 fileName of fileNames) { const arrayBuffer = await readContentOfZipEntry( zip, fileName, 'arraybuffer' ) if (arrayBuffer) { const hash = await getHash(arrayBuffer) if (hash) { fileHashes[fileName.replace(/^files\//, '')] = hash } } else { fileHashes[fileName.replace(/^files\//, '')] = null } } console.log('fileHashes :>> ', fileHashes) setCurrentFileHashes(fileHashes) setLoadingSpinnerDesc('Parsing meta.json') const metaFileContent = await readContentOfZipEntry( zip, 'meta.json', 'string' ) if (!metaFileContent) { setIsLoading(false) return } const parsedMetaJson = await parseJson<Meta>(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 const createSignatureEvent = await parseJson<Event>( parsedMetaJson.createSignature ).catch((err) => { console.log('err in parsing the createSignature event:>> ', err) toast.error( err.message || 'error occurred in parsing the create signature event' ) setIsLoading(false) return null }) if (!createSignatureEvent) return const isValidCreateSignature = verifyEvent(createSignatureEvent) if (!isValidCreateSignature) { toast.error('Create signature is invalid') setIsLoading(false) return } const createSignatureContent = await parseJson<CreateSignatureEventContent>( createSignatureEvent.content ).catch((err) => { console.log( `err in parsing the createSignature event's content :>> `, err ) toast.error( err.message || `error occurred in parsing the create signature event's content` ) setIsLoading(false) return null }) if (!createSignatureContent) return setIsLoading(false) } const handleExport = async () => { if (Object.entries(files).length === 0 || !meta || !usersPubkey) return const usersNpub = hexToNpub(usersPubkey) if ( !signers.includes(usersNpub) && !viewers.includes(usersNpub) && submittedBy !== usersNpub ) { return } setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') const prevSig = getLastSignersSig(meta, signers) if (!prevSig) return const signedEvent = await signEventForMetaFile( JSON.stringify({ prevSig }), nostrController, setIsLoading ) if (!signedEvent) return const exportSignature = JSON.stringify(signedEvent, null, 2) const updatedMeta = { ...meta, exportSignature } const stringifiedMeta = JSON.stringify(updatedMeta, null, 2) const zip = new JSZip() zip.file('meta.json', stringifiedMeta) const marks = extractMarksFromSignedMeta(updatedMeta) const marksByPage = groupMarksByPage(marks) for (const [fileName, pdf] of Object.entries(files)) { const pages = await addMarks(pdf.file, marksByPage) const blob = await convertToPdfBlob(pages) zip.file(`files/${fileName}`, blob) } 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 }) if (!arrayBuffer) return const blob = new Blob([arrayBuffer]) saveAs(blob, `exported-${unixNow()}.sigit.zip`) setIsLoading(false) } const displayExportedBy = () => { if (!meta || !meta.exportSignature) return null const exportSignatureString = meta.exportSignature try { const exportSignatureEvent = JSON.parse(exportSignatureString) as Event if (verifyEvent(exportSignatureEvent)) { const exportedBy = exportSignatureEvent.pubkey const profile = profiles[exportedBy] return ( <Tooltip title={ profile?.display_name || profile?.name || shorten(hexToNpub(exportedBy)) } placement="top" arrow disableInteractive > <TooltipChild> <UserAvatar pubkey={exportedBy} image={profile?.picture} /> </TooltipChild> </Tooltip> ) } else { toast.error(`Invalid export signature!`) return ( <Typography component="label" sx={{ color: 'red' }}> Invalid export signature </Typography> ) } } catch (error) { console.error(`An error occurred wile parsing exportSignature`, error) return null } } return ( <> {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} <Container className={styles.container}> {!meta && ( <> <Typography component="label" variant="h6"> Select exported zip file </Typography> <MuiFileInput placeholder="Select file" value={selectedFile} onChange={(value) => setSelectedFile(value)} InputProps={{ inputProps: { accept: '.sigit.zip' } }} /> {selectedFile && ( <Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}> <Button onClick={handleVerify} variant="contained"> Verify </Button> </Box> )} </> )} {meta && ( <StickySideColumns left={ <> {currentFile !== null && ( <FileList files={getCurrentUserFiles(files, currentFileHashes)} currentFile={currentFile} setCurrentFile={setCurrentFile} handleDownload={handleExport} /> )} {displayExportedBy()} </> } right={<UsersDetails meta={meta} />} > <SlimPdfView currentFile={currentFile} files={getCurrentUserFiles(files, currentFileHashes)} /> </StickySideColumns> )} </Container> </> ) }