release #111

Merged
b merged 39 commits from staging into main 2024-07-11 13:42:05 +00:00
7 changed files with 1246 additions and 714 deletions
Showing only changes of commit d737f4891f - Show all commits

View File

@ -378,6 +378,61 @@ export class NostrController extends EventEmitter {
throw new Error('Login method is undefined') 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 will capture the public key from the nostr extension or if no extension present
* function wil capture the public key from the local storage * function wil capture the public key from the local storage

View File

@ -22,7 +22,7 @@ import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username' import { UserComponent } from '../../components/username'
@ -33,6 +33,7 @@ import { Meta, ProfileMetadata, User, UserRole } from '../../types'
import { import {
encryptArrayBuffer, encryptArrayBuffer,
generateEncryptionKey, generateEncryptionKey,
generateKeysFile,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline, isOnline,
@ -49,15 +50,15 @@ import { HTML5Backend } from 'react-dnd-html5-backend'
import type { Identifier, XYCoord } from 'dnd-core' import type { Identifier, XYCoord } from 'dnd-core'
import { useDrag, useDrop } from 'react-dnd' import { useDrag, useDrop } from 'react-dnd'
import saveAs from 'file-saver' import saveAs from 'file-saver'
import CopyModal from '../../components/copyModal'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { uploadedFile } = location.state || {}
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [authUrl, setAuthUrl] = useState<string>() const [authUrl, setAuthUrl] = useState<string>()
@ -74,6 +75,12 @@ export const CreatePage = () => {
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
useEffect(() => {
if (uploadedFile) {
setSelectedFiles([uploadedFile])
}
}, [uploadedFile])
useEffect(() => { useEffect(() => {
if (usersPubkey) { if (usersPubkey) {
setUsers((prev) => { setUsers((prev) => {
@ -221,57 +228,67 @@ export const CreatePage = () => {
) )
} }
const handleCreate = async () => { // Validate inputs before proceeding
const validateInputs = (): boolean => {
if (!title.trim()) { if (!title.trim()) {
toast.error('Title can not be empty') toast.error('Title can not be empty')
return return false
} }
if (users.length === 0) { if (users.length === 0) {
toast.error( toast.error(
'No signer/viewer is provided. At least add one signer or viewer.' 'No signer/viewer is provided. At least add one signer or viewer.'
) )
return return false
} }
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
toast.error('No file is selected. Select at least 1 file') toast.error('No file is selected. Select at least 1 file')
return return false
} }
setIsLoading(true) return true
setLoadingSpinnerDesc('Generating hashes for files') }
const fileHashes: { [key: string]: string } = {} // Handle errors during file arrayBuffer conversion
const handleFileError = (file: File) => (err: any) => {
// generating file hashes
for (const file of selectedFiles) {
const arraybuffer = await file.arrayBuffer().catch((err) => {
console.log( console.log(
`err while getting arrayBuffer of file ${file.name} :>> `, `Error while getting arrayBuffer of file ${file.name} :>> `,
err err
) )
toast.error( toast.error(
err.message || `err while getting arrayBuffer of file ${file.name}` err.message || `Error while getting arrayBuffer of file ${file.name}`
) )
return null 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) const hash = await getHash(arraybuffer)
if (!hash) { if (!hash) {
setIsLoading(false) return null
return
} }
fileHashes[file.name] = hash 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() const zip = new JSZip()
// zipping files
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file) zip.file(`files/${file.name}`, file)
}) })
@ -280,6 +297,7 @@ export const CreatePage = () => {
const viewers = users.filter((user) => user.role === UserRole.viewer) const viewers = users.filter((user) => user.role === UserRole.viewer)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
const createSignature = await signEventForMetaFile( const createSignature = await signEventForMetaFile(
JSON.stringify({ JSON.stringify({
signers: signers.map((signer) => hexToNpub(signer.pubkey)), signers: signers.map((signer) => hexToNpub(signer.pubkey)),
@ -290,12 +308,27 @@ export const CreatePage = () => {
setIsLoading 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 // create content for meta file
const meta: Meta = { const meta: Meta = {
title, title,
createSignature: JSON.stringify(createSignature, null, 2), createSignature,
docSignatures: {} docSignatures: {}
} }
@ -304,68 +337,146 @@ export const CreatePage = () => {
zip.file('meta.json', stringifiedMeta) zip.file('meta.json', stringifiedMeta)
const metaHash = await getHash(stringifiedMeta) const metaHash = await getHash(stringifiedMeta)
if (!metaHash) return if (!metaHash) return null
const metaHashJson = { const metaHashJson = {
[usersPubkey!]: metaHash [usersPubkey!]: metaHash
} }
zip.file('hashes.json', JSON.stringify(metaHashJson, null, 2)) zip.file('hashes.json', JSON.stringify(metaHashJson, null, 2))
return metaHash
} catch (err) { } catch (err) {
console.error(err) console.error(err)
toast.error('An error occurred in converting meta json to string') 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') setLoadingSpinnerDesc('Generating zip file')
const arraybuffer = await zip const arraybuffer = await zip
.generateAsync({ .generateAsync({
type: 'arraybuffer', type: 'arraybuffer',
compression: 'DEFLATE', compression: 'DEFLATE',
compressionOptions: { compressionOptions: { level: 6 }
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') setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer( return encryptArrayBuffer(arraybuffer, encryptionKey)
arraybuffer, }
encryptionKey
).finally(() => setIsLoading(false))
// 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]) const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data
if (await isOnline()) { const file = new File([blob], `compressed.sigit`, {
setIsLoading(true) type: 'application/sigit'
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
}) })
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 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') 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) { if (signers.length > 0) {
await sendDM( await sendDM(
fileUrl, fileUrl,
@ -376,9 +487,7 @@ export const CreatePage = () => {
setAuthUrl setAuthUrl
) )
} else { } else {
// send DM to all viewers if no signer
for (const viewer of viewers) { for (const viewer of viewers) {
// todo: execute in parallel
await sendDM( await sendDM(
fileUrl, fileUrl,
encryptionKey, encryptionKey,
@ -389,27 +498,60 @@ export const CreatePage = () => {
) )
} }
} }
setIsLoading(false) }
navigate( // Manage offline scenarios for signing or viewing the file
`${appPrivateRoutes.sign}?file=${encodeURIComponent( const handleOfflineFlow = async (
fileUrl encryptedArrayBuffer: ArrayBuffer,
)}&key=${encodeURIComponent(encryptionKey)}` 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 { } else {
saveAs(blob, 'request.sigit') await handleOfflineFlow(encryptedArrayBuffer, encryptionKey)
setTextToCopy(encryptionKey)
setOpenCopyModel(true)
}
} }
navigate(appPrivateRoutes.sign, { state: { arrayBuffer } })
} }
if (authUrl) { if (authUrl) {
@ -506,15 +648,6 @@ export const CreatePage = () => {
</Button> </Button>
</Box> </Box>
</Box> </Box>
<CopyModal
open={openCopyModal}
handleClose={() => {
setOpenCopyModel(false)
navigate(appPrivateRoutes.sign)
}}
title="Decryption key for Sigit file"
textToCopy={textToCopy}
/>
</> </>
) )
} }

View File

@ -7,11 +7,61 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Button, Tooltip, Typography } from '@mui/material' import { Box, Button, Tooltip, Typography } from '@mui/material'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { appPrivateRoutes } from '../../routes' import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import styles from './style.module.scss' import styles from './style.module.scss'
import { useRef } from 'react'
import JSZip from 'jszip'
import { toast } from 'react-toastify'
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate() 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 ( return (
<Box className={styles.container}> <Box className={styles.container}>
@ -29,10 +79,16 @@ export const HomePage = () => {
} }
}} }}
> >
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<Button <Button
variant="outlined" variant="outlined"
startIcon={<Upload />} startIcon={<Upload />}
onClick={() => navigate(appPrivateRoutes.sign)} onClick={handleUploadClick}
> >
Upload Upload
</Button> </Button>

File diff suppressed because it is too large Load Diff

View 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>
)
}

View File

@ -32,14 +32,17 @@ import {
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Cancel, CheckCircle } from '@mui/icons-material' import { Cancel, CheckCircle } from '@mui/icons-material'
import { useLocation } from 'react-router-dom'
export const VerifyPage = () => { export const VerifyPage = () => {
const theme = useTheme() const theme = useTheme()
const textColor = theme.palette.getContrastText( const textColor = theme.palette.getContrastText(
theme.palette.background.paper theme.palette.background.paper
) )
const location = useLocation()
const { uploadedZip } = location.state || {}
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
@ -62,6 +65,12 @@ export const VerifyPage = () => {
{} {}
) )
useEffect(() => {
if (uploadedZip) {
setSelectedFile(uploadedZip)
}
}, [uploadedZip])
useEffect(() => { useEffect(() => {
if (zip) { if (zip) {
const generateCurrentFileHashes = async () => { const generateCurrentFileHashes = async () => {
@ -364,7 +373,7 @@ export const VerifyPage = () => {
onChange={(value) => setSelectedFile(value)} onChange={(value) => setSelectedFile(value)}
InputProps={{ InputProps={{
inputProps: { inputProps: {
accept: '.zip' accept: '.sigit.zip'
} }
}} }}
/> />

View File

@ -1,5 +1,10 @@
import axios from 'axios' import axios from 'axios'
import { EventTemplate } from 'nostr-tools' import {
EventTemplate,
generateSecretKey,
getPublicKey,
nip04
} from 'nostr-tools'
import { MetadataController, NostrController } from '../controllers' import { MetadataController, NostrController } from '../controllers'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { appPrivateRoutes } from '../routes' import { appPrivateRoutes } from '../routes'
@ -11,17 +16,12 @@ import { appPrivateRoutes } from '../routes'
* @returns The URL of the uploaded file. * @returns The URL of the uploaded file.
*/ */
export const uploadToFileStorage = async ( export const uploadToFileStorage = async (
blob: Blob, file: File,
nostrController: NostrController nostrController: NostrController
) => { ) => {
// Get the current timestamp in seconds // Get the current timestamp in seconds
const unixNow = Math.floor(Date.now() / 1000) 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 // Define event metadata for authorization
const event: EventTemplate = { const event: EventTemplate = {
kind: 24242, kind: 24242,
@ -205,3 +205,52 @@ export const signEventForMetaFile = async (
return signedEvent // Return the signed event 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
}
}