sigit.io/src/pages/verify/index.tsx
enes 6652c6519e
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 9s
refactor(review): add date util functions
2024-08-13 11:52:05 +02:00

725 lines
21 KiB
TypeScript

import {
Box,
Button,
List,
ListItem,
ListSubheader,
Tooltip,
Typography,
useTheme
} from '@mui/material'
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { Event, kinds, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController, NostrController } from '../../controllers'
import {
CreateSignatureEventContent,
Meta,
ProfileMetadata,
SignedEventContent
} from '../../types'
import {
decryptArrayBuffer,
extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey,
getHash,
hexToNpub,
unixNow,
npubToHex,
parseJson,
readContentOfZipEntry,
shorten,
signEventForMetaFile
} from '../../utils'
import styles from './style.module.scss'
import { Cancel, CheckCircle } from '@mui/icons-material'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import { PdfFile } from '../../types/drawing.ts'
import {
addMarks,
convertToPdfBlob,
convertToPdfFile,
groupMarksByPage
} 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'
export const VerifyPage = () => {
const theme = useTheme()
const textColor = theme.palette.getContrastText(
theme.palette.background.paper
)
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: metaInNavState } = location.state || {}
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [meta, setMeta] = useState<Meta | null>(null)
const [submittedBy, setSubmittedBy] = useState<string>()
const [signers, setSigners] = useState<`npub1${string}`[]>([])
const [viewers, setViewers] = useState<`npub1${string}`[]>([])
const [creatorFileHashes, setCreatorFileHashes] = useState<{
[key: string]: string
}>({})
const [currentFileHashes, setCurrentFileHashes] = useState<{
[key: string]: string | null
}>({})
const [files, setFiles] = useState<{ [filename: string]: PdfFile }>({})
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
useEffect(() => {
if (uploadedZip) {
setSelectedFile(uploadedZip)
} else if (metaInNavState) {
const processSigit = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Extracting zipUrl and encryption key from meta')
const res = await extractZipUrlAndEncryptionKey(metaInNavState)
if (!res) {
setIsLoading(false)
return
}
const {
zipUrl,
encryptionKey,
createSignatureEvent,
createSignatureContent
} = res
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)
setSigners(createSignatureContent.signers)
setViewers(createSignatureContent.viewers)
setCreatorFileHashes(createSignatureContent.fileHashes)
setSubmittedBy(createSignatureEvent.pubkey)
setMeta(metaInNavState)
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()
}
}, [uploadedZip, metaInNavState])
useEffect(() => {
if (submittedBy) {
const metadataController = new MetadataController()
const users = [submittedBy, ...signers, ...viewers]
users.forEach((user) => {
const pubkey = npubToHex(user)!
if (!(pubkey in metadata)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[pubkey]: metadataContent
}))
}
metadataController.on(pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(pubkey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${user}`,
err
)
})
}
})
}
}, [submittedBy, signers, viewers, metadata])
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
setSigners(createSignatureContent.signers)
setViewers(createSignatureContent.viewers)
setCreatorFileHashes(createSignatureContent.fileHashes)
setSubmittedBy(createSignatureEvent.pubkey)
setMeta(parsedMetaJson)
setIsLoading(false)
}
const getPrevSignersSig = (npub: string) => {
if (!meta) return null
// if user is first signer then use creator's signature
if (signers[0] === npub) {
try {
const createSignatureEvent: Event = JSON.parse(meta.createSignature)
return createSignatureEvent.sig
} catch (error) {
return null
}
}
// find the index of signer
const currentSignerIndex = signers.findIndex((signer) => signer === npub)
// return null if could not found user in signer's list
if (currentSignerIndex === -1) return null
// find prev signer
const prevSigner = signers[currentSignerIndex - 1]
// get the signature of prev signer
try {
const prevSignersEvent: Event = JSON.parse(meta.docSignatures[prevSigner])
return prevSignersEvent.sig
} catch (error) {
return null
}
}
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')
if (!meta) return
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 displayUser = (pubkey: string, verifySignature = false) => {
const profile = metadata[pubkey]
let isValidSignature = false
if (verifySignature) {
const npub = hexToNpub(pubkey)
const signedEventString = meta ? meta.docSignatures[npub] : null
if (signedEventString) {
try {
const signedEvent = JSON.parse(signedEventString)
const isVerifiedEvent = verifyEvent(signedEvent)
if (isVerifiedEvent) {
// get the actual signature of prev signer
const prevSignersSig = getPrevSignersSig(npub)
// get the signature of prev signer from the content of current signers signedEvent
try {
const obj: SignedEventContent = JSON.parse(signedEvent.content)
if (
obj.prevSig &&
prevSignersSig &&
obj.prevSig === prevSignersSig
) {
isValidSignature = true
}
} catch (error) {
isValidSignature = false
}
}
} catch (error) {
console.error(
`An error occurred in parsing and verifying the signature event for ${pubkey}`,
error
)
}
}
}
return (
<>
<UserAvatar
pubkey={pubkey}
name={
profile?.display_name || profile?.name || shorten(hexToNpub(pubkey))
}
image={profile?.picture}
/>
{verifySignature && (
<>
{isValidSignature && (
<Tooltip title="Valid signature">
<CheckCircle sx={{ color: theme.palette.success.light }} />
</Tooltip>
)}
{!isValidSignature && (
<Tooltip title="Invalid signature">
<Cancel sx={{ color: theme.palette.error.main }} />
</Tooltip>
)}
</>
)}
</>
)
}
const displayExportedBy = () => {
if (!meta || !meta.exportSignature) return null
const exportSignatureString = meta.exportSignature
try {
const exportSignatureEvent = JSON.parse(exportSignatureString) as Event
if (verifyEvent(exportSignatureEvent)) {
return displayUser(exportSignatureEvent.pubkey)
} 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 && (
<>
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader className={styles.subHeader}>
Meta Info
</ListSubheader>
}
>
{submittedBy && (
<ListItem
sx={{
marginTop: 1,
gap: '15px'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Submitted By
</Typography>
{displayUser(submittedBy)}
</ListItem>
)}
<ListItem
sx={{
marginTop: 1,
gap: '15px'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Exported By
</Typography>
{displayExportedBy()}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
</ListItem>
{signers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Signers
</Typography>
<ul className={styles.usersList}>
{signers.map((signer) => (
<li
key={signer}
style={{
color: textColor,
display: 'flex',
alignItems: 'center',
gap: '15px'
}}
>
{displayUser(npubToHex(signer)!, true)}
</li>
))}
</ul>
</ListItem>
)}
{viewers.length > 0 && (
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Viewers
</Typography>
<ul className={styles.usersList}>
{viewers.map((viewer) => (
<li key={viewer} style={{ color: textColor }}>
{displayUser(npubToHex(viewer)!)}
</li>
))}
</ul>
</ListItem>
)}
<ListItem
sx={{
marginTop: 1,
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Typography variant="h6" sx={{ color: textColor }}>
Files
</Typography>
<Box className={styles.filesWrapper}>
{Object.entries(currentFileHashes).map(
([filename, hash], index) => {
const isValidHash = creatorFileHashes[filename] === hash
return (
<Box key={`file-${index}`} className={styles.file}>
<Typography
component="label"
sx={{
color: textColor,
flexGrow: 1
}}
>
{filename}
</Typography>
{isValidHash && (
<Tooltip title="File integrity check passed" arrow>
<CheckCircle
sx={{ color: theme.palette.success.light }}
/>
</Tooltip>
)}
{!isValidHash && (
<Tooltip title="File integrity check failed" arrow>
<Cancel
sx={{ color: theme.palette.error.main }}
/>
</Tooltip>
)}
</Box>
)
}
)}
</Box>
</ListItem>
</List>
</>
)}
</Container>
</>
)
}