Merge pull request 'In offline mode create a wrapper zip file' (#110) from issue-109 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m7s
Reviewed-on: #110
This commit is contained in:
commit
d737f4891f
@ -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
|
||||
|
@ -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,15 +50,15 @@ 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'
|
||||
|
||||
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<string>()
|
||||
|
||||
@ -74,6 +75,12 @@ export const CreatePage = () => {
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedFile) {
|
||||
setSelectedFiles([uploadedFile])
|
||||
}
|
||||
}, [uploadedFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (usersPubkey) {
|
||||
setUsers((prev) => {
|
||||
@ -221,57 +228,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.'
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
const fileHashes: { [key: string]: string } = {}
|
||||
|
||||
// generating file hashes
|
||||
for (const file of selectedFiles) {
|
||||
const arraybuffer = await file.arrayBuffer().catch((err) => {
|
||||
// Handle errors during file arrayBuffer conversion
|
||||
const handleFileError = (file: File) => (err: any) => {
|
||||
console.log(
|
||||
`err while getting arrayBuffer of file ${file.name} :>> `,
|
||||
`Error while getting arrayBuffer of file ${file.name} :>> `,
|
||||
err
|
||||
)
|
||||
toast.error(
|
||||
err.message || `err while getting arrayBuffer of file ${file.name}`
|
||||
err.message || `Error while getting arrayBuffer of file ${file.name}`
|
||||
)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
if (!arraybuffer) return
|
||||
// Generate hash for each selected file
|
||||
const generateFileHashes = async (): Promise<{
|
||||
[key: string]: string
|
||||
} | null> => {
|
||||
const fileHashes: { [key: string]: string } = {}
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
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)
|
||||
})
|
||||
@ -280,6 +297,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)),
|
||||
@ -290,12 +308,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<string | null> => {
|
||||
// create content for meta file
|
||||
const meta: Meta = {
|
||||
title,
|
||||
createSignature: JSON.stringify(createSignature, null, 2),
|
||||
createSignature,
|
||||
docSignatures: {}
|
||||
}
|
||||
|
||||
@ -304,68 +337,146 @@ 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<ArrayBuffer | null> => {
|
||||
setLoadingSpinnerDesc('Generating zip file')
|
||||
|
||||
const arraybuffer = await zip
|
||||
.generateAsync({
|
||||
type: 'arraybuffer',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: {
|
||||
level: 6
|
||||
compressionOptions: { level: 6 }
|
||||
})
|
||||
.catch(handleZipError)
|
||||
|
||||
return arraybuffer
|
||||
}
|
||||
})
|
||||
.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 encryptionKey = await generateEncryptionKey()
|
||||
|
||||
// Encrypt the zip file with the generated encryption key
|
||||
const encryptZipFile = async (
|
||||
arraybuffer: ArrayBuffer,
|
||||
encryptionKey: string
|
||||
): Promise<ArrayBuffer> => {
|
||||
setLoadingSpinnerDesc('Encrypting zip file')
|
||||
const encryptedArrayBuffer = await encryptArrayBuffer(
|
||||
arraybuffer,
|
||||
encryptionKey
|
||||
).finally(() => setIsLoading(false))
|
||||
return encryptArrayBuffer(arraybuffer, encryptionKey)
|
||||
}
|
||||
|
||||
// create final zip file
|
||||
const createFinalZipFile = async (
|
||||
encryptedArrayBuffer: ArrayBuffer,
|
||||
encryptionKey: string
|
||||
): Promise<File | null> => {
|
||||
// Get the current timestamp in seconds
|
||||
const unixNow = Math.floor(Date.now() / 1000)
|
||||
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
|
||||
// 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<string | null> => {
|
||||
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')
|
||||
|
||||
// send DM to first signer if exists
|
||||
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,
|
||||
@ -376,9 +487,7 @@ export const CreatePage = () => {
|
||||
setAuthUrl
|
||||
)
|
||||
} else {
|
||||
// send DM to all viewers if no signer
|
||||
for (const viewer of viewers) {
|
||||
// todo: execute in parallel
|
||||
await sendDM(
|
||||
fileUrl,
|
||||
encryptionKey,
|
||||
@ -389,27 +498,60 @@ export const CreatePage = () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
navigate(
|
||||
`${appPrivateRoutes.sign}?file=${encodeURIComponent(
|
||||
fileUrl
|
||||
)}&key=${encodeURIComponent(encryptionKey)}`
|
||||
// Manage offline scenarios for signing or viewing the file
|
||||
const handleOfflineFlow = async (
|
||||
encryptedArrayBuffer: ArrayBuffer,
|
||||
encryptionKey: string
|
||||
) => {
|
||||
const finalZipFile = await createFinalZipFile(
|
||||
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 } })
|
||||
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 encryptZipFile(
|
||||
arrayBuffer,
|
||||
encryptionKey
|
||||
)
|
||||
|
||||
if (await isOnline()) {
|
||||
await handleOnlineFlow(encryptedArrayBuffer, encryptionKey)
|
||||
} else {
|
||||
saveAs(blob, 'request.sigit')
|
||||
setTextToCopy(encryptionKey)
|
||||
setOpenCopyModel(true)
|
||||
}
|
||||
await handleOfflineFlow(encryptedArrayBuffer, encryptionKey)
|
||||
}
|
||||
|
||||
navigate(appPrivateRoutes.sign, { state: { arrayBuffer } })
|
||||
}
|
||||
|
||||
if (authUrl) {
|
||||
@ -506,15 +648,6 @@ export const CreatePage = () => {
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<CopyModal
|
||||
open={openCopyModal}
|
||||
handleClose={() => {
|
||||
setOpenCopyModel(false)
|
||||
navigate(appPrivateRoutes.sign)
|
||||
}}
|
||||
title="Decryption key for Sigit file"
|
||||
textToCopy={textToCopy}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -7,11 +7,61 @@ import {
|
||||
} from '@mui/icons-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<HTMLInputElement>(null)
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click()
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
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 (
|
||||
<Box className={styles.container}>
|
||||
@ -29,10 +79,16 @@ export const HomePage = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Upload />}
|
||||
onClick={() => navigate(appPrivateRoutes.sign)}
|
||||
onClick={handleUploadClick}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
|
File diff suppressed because it is too large
Load Diff
426
src/pages/sign/internal/displayMeta.tsx
Normal file
426
src/pages/sign/internal/displayMeta.tsx
Normal file
@ -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<User[]>([])
|
||||
|
||||
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 (
|
||||
<List
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
marginTop: 2
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader className={styles.subHeader}>Meta Info</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
gap: '15px'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: textColor }}>
|
||||
Submitted By
|
||||
</Typography>
|
||||
{(function () {
|
||||
const profile = metadata[submittedBy]
|
||||
return (
|
||||
<UserComponent
|
||||
pubkey={submittedBy}
|
||||
name={
|
||||
profile?.display_name ||
|
||||
profile?.name ||
|
||||
shorten(hexToNpub(submittedBy))
|
||||
}
|
||||
image={profile?.picture}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</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}>
|
||||
<Tooltip title="Download File" arrow>
|
||||
<IconButton onClick={() => downloadFile(filename)}>
|
||||
<Download />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<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>
|
||||
<ListItem sx={{ marginTop: 1 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className={styles.tableCell}>User</TableCell>
|
||||
<TableCell className={styles.tableCell}>Role</TableCell>
|
||||
<TableCell>Signed Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<DisplayUser
|
||||
key={user.pubkey}
|
||||
meta={meta}
|
||||
user={user}
|
||||
metadata={metadata}
|
||||
signedBy={signedBy}
|
||||
nextSigner={nextSigner}
|
||||
getPrevSignersSig={getPrevSignersSig}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ListItem>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
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>(UserStatus.Pending)
|
||||
const [prevSignatureStatus, setPreviousSignatureStatus] =
|
||||
useState<PrevSignatureValidationEnum>(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<Event>(
|
||||
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<SignedEventContent>(
|
||||
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 (
|
||||
<TableRow>
|
||||
<TableCell className={styles.tableCell}>
|
||||
<UserComponent
|
||||
pubkey={user.pubkey}
|
||||
name={
|
||||
userMeta?.display_name ||
|
||||
userMeta?.name ||
|
||||
shorten(hexToNpub(user.pubkey))
|
||||
}
|
||||
image={userMeta?.picture}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={styles.tableCell}>{user.role}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Typography component="label">{userStatus}</Typography>
|
||||
{userStatus === UserStatus.Signed && (
|
||||
<>
|
||||
{prevSignatureStatus === PrevSignatureValidationEnum.Valid && (
|
||||
<Tooltip title="Contains valid signature of prev signer" arrow>
|
||||
<CheckCircle sx={{ color: theme.palette.success.light }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{prevSignatureStatus === PrevSignatureValidationEnum.Invalid && (
|
||||
<Tooltip
|
||||
title="Contains invalid signature of prev signer"
|
||||
arrow
|
||||
>
|
||||
<Cancel sx={{ color: theme.palette.error.main }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{userStatus === UserStatus.Awaiting && (
|
||||
<Tooltip title="Waiting for user's sign" arrow>
|
||||
<HourglassTop />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
@ -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'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -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<string | null> => {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user