sigit.io/src/pages/verify/index.tsx

802 lines
24 KiB
TypeScript
Raw Normal View History

2024-09-13 17:53:22 +02:00
import { Box, Button, Typography } from '@mui/material'
2024-05-14 14:27:05 +05:00
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { useCallback, useEffect, useRef, useState } from 'react'
2024-05-14 14:27:05 +05:00
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import {
DocSignatureEvent,
Meta,
SignedEvent,
OpenTimestamp,
OpenTimestampUpgradeVerifyResponse
} from '../../types'
2024-05-14 14:27:05 +05:00
import {
decryptArrayBuffer,
2024-05-22 11:19:40 +05:00
getHash,
hexToNpub,
unixNow,
2024-05-14 14:27:05 +05:00
parseJson,
readContentOfZipEntry,
signEventForMetaFile,
getCurrentUserFiles,
npubToHex,
generateEncryptionKey,
encryptArrayBuffer,
generateKeysFile,
ARRAY_BUFFER,
DEFLATE,
uploadMetaToFileStorage,
decrypt
2024-05-14 14:27:05 +05:00
} from '../../utils'
import styles from './style.module.scss'
import { useLocation, useParams } from 'react-router-dom'
2024-07-05 13:38:04 +05:00
import axios from 'axios'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useAppSelector, useNDK } from '../../hooks'
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'
2024-08-21 11:26:17 +02:00
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'
2024-09-04 14:05:36 +02:00
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'
import { SignerService } from '../../services/index.ts'
2024-05-14 14:27:05 +05:00
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 (
<div className="files-wrapper">
{files.length > 0 ? (
files.map((currentUserFile, i) => {
const { hash, file, id } = currentUserFile
const signatureEvents = Object.keys(parsedSignatureEvents)
if (!hash) return
return (
<React.Fragment key={file.name}>
<div
id={file.name}
ref={(el) => (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(
2025-01-09 12:58:23 +02:00
(m) =>
(m.pdfFileHash
? m.pdfFileHash == hash
: m.fileHash == hash) && m.location.page == i
)
if (m) {
marks.push(...m)
}
})
return (
<div className="image-wrapper" key={i}>
<img
draggable="false"
src={page.image}
alt={`page ${i} of ${file.name}`}
/>
{marks.map((m) => {
return (
<div
className={`file-mark ${styles.mark}`}
key={m.id}
style={{
left: inPx(from(page.width, m.location.left)),
top: inPx(from(page.width, m.location.top)),
width: inPx(from(page.width, m.location.width)),
height: inPx(
from(page.width, m.location.height)
),
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
<MarkRender
markType={m.type}
value={m.value}
mark={m}
/>
</div>
)
})}
</div>
)
})}
{file.isImage && (
<img
className="file-image"
src={file.objectUrl}
alt={file.name}
/>
)}
{!(file.isPdf || file.isImage) && (
<ExtensionFileBox extension={file.extension} />
)}
</div>
{i < files.length - 1 && <FileDivider />}
</React.Fragment>
)
})
) : (
<LoadingSpinner variant="small" />
)}
</div>
2024-05-16 10:40:56 +05:00
)
}
2024-05-14 14:27:05 +05:00
export const VerifyPage = () => {
const location = useLocation()
const params = useParams()
const { updateUsersAppData, sendNotification } = useNDK()
const usersAppData = useAppSelector((state) => state.userAppData)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
2024-09-13 17:53:22 +02:00
const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
2024-07-09 01:16:47 +05:00
/**
* 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?.uploadedZip || undefined
2024-09-13 17:53:22 +02:00
const [selectedFile, setSelectedFile] = useState<File | null>(null)
2024-11-04 22:04:27 +01:00
/**
* 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
}
}
}
2024-09-13 17:53:22 +02:00
const [meta, setMeta] = useState<Meta>(metaInNavState)
const {
submittedBy,
zipUrl,
encryptionKey,
signers,
viewers,
fileHashes,
parsedSignatureEvents,
timestamps
} = useSigitMeta(meta)
2024-09-13 17:53:22 +02:00
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
2024-05-22 11:19:40 +05:00
2024-09-13 17:53:22 +02:00
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
2024-05-22 11:19:40 +05:00
const [currentFileHashes, setCurrentFileHashes] = useState<{
[key: string]: string | null
}>({})
const signTimestampEvent = async (signerContent: {
timestamps: OpenTimestamp[]
}): Promise<SignedEvent | null> => {
return await signEventForMetaFile(
JSON.stringify(signerContent),
nostrController,
setIsLoading
)
}
useEffect(() => {
if (Object.entries(files).length > 0) {
2024-09-13 17:53:22 +02:00
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
setCurrentFile(tmp[0])
}
2024-09-13 17:53:22 +02:00
}, [currentFileHashes, fileHashes, files])
2024-05-14 14:27:05 +05:00
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
})
2024-10-24 12:54:47 +03:00
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)!, {
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(() => {
2024-09-13 17:53:22 +02:00
if (metaInNavState && encryptionKey) {
2024-07-05 13:38:04 +05:00
const processSigit = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Fetching file from file server')
try {
const res = await axios.get(zipUrl, {
2024-07-05 13:38:04 +05:00
responseType: 'arraybuffer'
})
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)
2024-07-05 13:38:04 +05:00
toast.error(
err.message || 'An error occurred in loading zip file.'
2024-07-05 13:38:04 +05:00
)
return null
})
if (!zip) return
2024-07-05 13:38:04 +05:00
const files: { [fileName: string]: SigitFile } = {}
const fileHashes: { [key: string]: string | null } = {}
const fileNames = Object.values(zip.files).map(
(entry) => entry.name
)
2024-07-05 13:38:04 +05:00
// 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'
2024-07-05 13:38:04 +05:00
)
if (arrayBuffer) {
files[fileName] = await convertToSigitFile(
arrayBuffer,
fileName!
2024-07-05 13:38:04 +05:00
)
const hash = await getHash(arrayBuffer)
2024-07-05 13:38:04 +05:00
if (hash) {
fileHashes[fileName.replace(/^files\//, '')] = hash
2024-07-05 13:38:04 +05:00
}
} else {
fileHashes[fileName.replace(/^files\//, '')] = null
2024-07-05 13:38:04 +05:00
}
}
setCurrentFileHashes(fileHashes)
setFiles(files)
2024-07-05 13:38:04 +05:00
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)
}
2024-05-22 11:19:40 +05:00
}
2024-07-05 13:38:04 +05:00
processSigit()
2024-05-22 11:19:40 +05:00
}
2024-09-13 17:53:22 +02:00
}, [encryptionKey, metaInNavState, zipUrl])
2024-05-14 14:27:05 +05:00
const handleVerify = useCallback(async (selectedFile: File) => {
2024-05-16 10:40:56 +05:00
setIsLoading(true)
setLoadingSpinnerDesc('Loading zip file')
2024-05-14 14:27:05 +05:00
let zip = await JSZip.loadAsync(selectedFile).catch((err) => {
2024-05-14 14:27:05 +05:00
console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.')
return null
})
if (!zip) {
return setIsLoading(false)
}
if ('keys.json' in zip.files) {
// Decrypt
setLoadingSpinnerDesc('Decrypting zip file content')
const arrayBuffer = await decrypt(selectedFile).catch((err) => {
console.error(`error occurred in decryption`, err)
toast.error(err.message || `error occurred in decryption`)
})
if (arrayBuffer) {
// Replace the zip and continue processing
zip = await JSZip.loadAsync(arrayBuffer)
}
}
setLoadingSpinnerDesc('Opening zip file content')
2024-07-05 13:38:04 +05:00
2024-09-13 17:53:22 +02:00
const files: { [filename: string]: SigitFile } = {}
2024-07-05 13:38:04 +05:00
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
2024-09-13 17:53:22 +02:00
for (const zipFilePath of fileNames) {
2024-07-05 13:38:04 +05:00
const arrayBuffer = await readContentOfZipEntry(
zip,
2024-09-13 17:53:22 +02:00
zipFilePath,
2024-07-05 13:38:04 +05:00
'arraybuffer'
)
2024-09-13 17:53:22 +02:00
const fileName = zipFilePath.replace(/^files\//, '')
2024-07-05 13:38:04 +05:00
if (arrayBuffer) {
2024-09-13 17:53:22 +02:00
files[fileName] = await convertToSigitFile(arrayBuffer, fileName)
2024-07-05 13:38:04 +05:00
const hash = await getHash(arrayBuffer)
if (hash) {
2024-09-13 17:53:22 +02:00
fileHashes[fileName] = hash
2024-07-05 13:38:04 +05:00
}
} else {
2024-09-13 17:53:22 +02:00
fileHashes[fileName] = null
2024-07-05 13:38:04 +05:00
}
}
2024-09-13 17:53:22 +02:00
setFiles(files)
2024-07-05 13:38:04 +05:00
setCurrentFileHashes(fileHashes)
2024-05-14 14:27:05 +05:00
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) {
setIsLoading(false)
return
}
2024-05-22 11:19:40 +05:00
2024-09-13 17:53:22 +02:00
setMeta(parsedMetaJson)
2024-05-22 11:19:40 +05:00
2024-05-16 10:40:56 +05:00
setIsLoading(false)
}, [])
useEffect(() => {
if (uploadedZip) {
handleVerify(uploadedZip)
}
}, [handleVerify, uploadedZip])
2024-05-14 14:27:05 +05:00
// 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
}
// create final zip file
const createFinalZipFile = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
): Promise<File | null> => {
// 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 userSet = new Set<string>()
if (submittedBy) {
userSet.add(submittedBy)
}
signers.forEach((signer) => {
userSet.add(npubToHex(signer)!)
})
viewers.forEach((viewer) => {
userSet.add(npubToHex(viewer)!)
})
const keysFileContent = await generateKeysFile(
Array.from(userSet),
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) {
setIsLoading(false)
return
}
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const handleEncryptedExport = async () => {
const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) {
setIsLoading(false)
return
}
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) {
setIsLoading(false)
return
}
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const prepareZipExport = async (): Promise<ArrayBuffer | null> => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey)
return Promise.resolve(null)
const usersNpub = hexToNpub(usersPubkey)
if (
!signers.includes(usersNpub) &&
!viewers.includes(usersNpub) &&
submittedBy !== usersNpub
) {
return Promise.resolve(null)
}
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return Promise.resolve(null)
const signerService = new SignerService(meta)
const prevSig = signerService.getLastSignerSig()
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)
}
2024-05-14 14:27:05 +05:00
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}>
2024-05-16 10:40:56 +05:00
{!meta && (
2024-05-14 14:27:05 +05:00
<>
2024-05-15 11:50:21 +03:00
<Typography component="label" variant="h6">
2024-05-16 10:40:56 +05:00
Select exported zip file
2024-05-14 14:27:05 +05:00
</Typography>
2024-05-16 10:40:56 +05:00
<MuiFileInput
2024-05-16 11:25:30 +05:00
placeholder="Select file"
2024-05-16 10:40:56 +05:00
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
InputProps={{
inputProps: {
accept: '.sigit.zip'
2024-05-16 10:40:56 +05:00
}
}}
/>
2024-05-14 14:27:05 +05:00
2024-05-16 10:40:56 +05:00
{selectedFile && (
2024-05-15 11:19:28 +05:00
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button
onClick={() => handleVerify(selectedFile)}
variant="contained"
>
2024-05-16 10:40:56 +05:00
Verify
2024-05-14 14:27:05 +05:00
</Button>
</Box>
)}
</>
)}
2024-05-16 10:40:56 +05:00
{meta && (
<StickySideColumns
left={
2024-09-13 17:53:22 +02:00
currentFile !== null && (
<FileList
files={getCurrentUserFiles(
files,
currentFileHashes,
fileHashes
)}
currentFile={currentFile}
setCurrentFile={setCurrentFile}
handleExport={handleExport}
handleEncryptedExport={handleEncryptedExport}
2024-09-13 17:53:22 +02:00
/>
)
}
right={<UsersDetails meta={meta} />}
2024-09-04 14:05:36 +02:00
leftIcon={faFileDownload}
centerIcon={faFile}
rightIcon={faCircleInfo}
>
<SlimPdfView
currentFile={currentFile}
2024-09-13 17:53:22 +02:00
files={getCurrentUserFiles(files, currentFileHashes, fileHashes)}
parsedSignatureEvents={parsedSignatureEvents}
/>
</StickySideColumns>
2024-05-14 14:27:05 +05:00
)}
</Container>
2024-05-14 14:27:05 +05:00
</>
)
}