feat: implemented the UI and logic for signing document
All checks were successful
Release / build_and_release (push) Successful in 55s
All checks were successful
Release / build_and_release (push) Successful in 55s
This commit is contained in:
parent
c4ef090f3c
commit
a32abaf9e7
@ -135,6 +135,12 @@ export const AppBar = () => {
|
|||||||
to={appPrivateRoutes.decryptZip}
|
to={appPrivateRoutes.decryptZip}
|
||||||
component={Link}
|
component={Link}
|
||||||
/>
|
/>
|
||||||
|
<Tab
|
||||||
|
label='Sign Document'
|
||||||
|
value={appPrivateRoutes.sign}
|
||||||
|
to={appPrivateRoutes.sign}
|
||||||
|
component={Link}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@ -201,6 +207,18 @@ export const AppBar = () => {
|
|||||||
Decrypt Zip
|
Decrypt Zip
|
||||||
</Button>
|
</Button>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem sx={{ justifyContent: 'center' }}>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={appPrivateRoutes.sign}
|
||||||
|
onClick={handleCloseNavMenu}
|
||||||
|
variant='contained'
|
||||||
|
color='primary'
|
||||||
|
>
|
||||||
|
Sign Document
|
||||||
|
</Button>
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -13,10 +13,8 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import axios from 'axios'
|
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import { MuiFileInput } from 'mui-file-input'
|
import { MuiFileInput } from 'mui-file-input'
|
||||||
import { EventTemplate } from 'nostr-tools'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
@ -30,10 +28,13 @@ import { ProfileMetadata } from '../../types'
|
|||||||
import {
|
import {
|
||||||
encryptArrayBuffer,
|
encryptArrayBuffer,
|
||||||
generateEncryptionKey,
|
generateEncryptionKey,
|
||||||
getFileHash,
|
getHash,
|
||||||
pubToHex,
|
pubToHex,
|
||||||
queryNip05,
|
queryNip05,
|
||||||
shorten
|
sendDM,
|
||||||
|
shorten,
|
||||||
|
signEventForMetaFile,
|
||||||
|
uploadToFileStorage
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
import styles from './style.module.scss'
|
import styles from './style.module.scss'
|
||||||
|
|
||||||
@ -162,7 +163,25 @@ export const HomePage = () => {
|
|||||||
const fileHashes: { [key: string]: string } = {}
|
const fileHashes: { [key: string]: string } = {}
|
||||||
|
|
||||||
for (const file of selectedFiles) {
|
for (const file of selectedFiles) {
|
||||||
const hash = await getFileHash(file)
|
const arraybuffer = await file.arrayBuffer().catch((err) => {
|
||||||
|
console.log(
|
||||||
|
`err while getting arrayBuffer of file ${file.name} :>> `,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
toast.error(
|
||||||
|
err.message || `err while getting arrayBuffer of file ${file.name}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!arraybuffer) return
|
||||||
|
|
||||||
|
const hash = await getHash(arraybuffer)
|
||||||
|
|
||||||
|
if (!hash) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fileHashes[file.name] = hash
|
fileHashes[file.name] = hash
|
||||||
}
|
}
|
||||||
@ -173,20 +192,13 @@ export const HomePage = () => {
|
|||||||
zip.file(`files/${file.name}`, file)
|
zip.file(`files/${file.name}`, file)
|
||||||
})
|
})
|
||||||
|
|
||||||
const event: EventTemplate = {
|
|
||||||
kind: 1,
|
|
||||||
tags: [['r', signers[0]]],
|
|
||||||
content: JSON.stringify(fileHashes),
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Signing nostr event')
|
setLoadingSpinnerDesc('Signing nostr event')
|
||||||
const signedEvent = await nostrController.signEvent(event).catch((err) => {
|
const signedEvent = await signEventForMetaFile(
|
||||||
console.error(err)
|
signers[0],
|
||||||
toast.error(err.message || 'Error occurred in signing nostr event')
|
fileHashes,
|
||||||
setIsLoading(false)
|
nostrController,
|
||||||
return null
|
setIsLoading
|
||||||
})
|
)
|
||||||
|
|
||||||
if (!signedEvent) return
|
if (!signedEvent) return
|
||||||
|
|
||||||
@ -238,7 +250,7 @@ export const HomePage = () => {
|
|||||||
const blob = new Blob([encryptedArrayBuffer])
|
const blob = new Blob([encryptedArrayBuffer])
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Uploading zip file to file storage.')
|
setLoadingSpinnerDesc('Uploading zip file to file storage.')
|
||||||
const fileUrl = await uploadToFileStorage(blob)
|
const fileUrl = await uploadToFileStorage(blob, nostrController)
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
toast.success('zip file uploaded to file storage')
|
toast.success('zip file uploaded to file storage')
|
||||||
return url
|
return url
|
||||||
@ -251,134 +263,19 @@ export const HomePage = () => {
|
|||||||
|
|
||||||
if (!fileUrl) return
|
if (!fileUrl) return
|
||||||
|
|
||||||
await sendDMToFirstSigner(fileUrl, encryptionKey, signers[0])
|
setLoadingSpinnerDesc('Sending DM to first signer')
|
||||||
|
await sendDM(
|
||||||
|
fileUrl,
|
||||||
|
encryptionKey,
|
||||||
|
signers[0],
|
||||||
|
nostrController,
|
||||||
|
true,
|
||||||
|
setAuthUrl
|
||||||
|
)
|
||||||
|
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadToFileStorage = async (blob: Blob) => {
|
|
||||||
const unixNow = Math.floor(Date.now() / 1000)
|
|
||||||
|
|
||||||
const file = new File([blob], `zipped-${unixNow}.zip`, {
|
|
||||||
type: 'application/zip'
|
|
||||||
})
|
|
||||||
|
|
||||||
const event: EventTemplate = {
|
|
||||||
kind: 24242,
|
|
||||||
content: 'Authorize Upload',
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['t', 'upload'],
|
|
||||||
['expiration', String(unixNow + 60 * 5)],
|
|
||||||
['name', file.name],
|
|
||||||
['size', String(file.size)]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Signing auth event for uploading zip')
|
|
||||||
const authEvent = await nostrController.signEvent(event)
|
|
||||||
|
|
||||||
// todo: use env variable
|
|
||||||
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
|
|
||||||
|
|
||||||
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)),
|
|
||||||
'Content-Type': 'application/zip'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return response.data.url as string
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendDMToFirstSigner = async (
|
|
||||||
fileUrl: string,
|
|
||||||
encryptionKey: string,
|
|
||||||
pubkey: string
|
|
||||||
) => {
|
|
||||||
const content = `You have been requested for a signature.\nHere is the url for zip file that you can download.\n
|
|
||||||
${fileUrl}\nHowever this zip file is encrypted and you need to decrypt it using https://app.sigit.io\n Encryption key: ${encryptionKey}`
|
|
||||||
|
|
||||||
nostrController.on('nsecbunker-auth', (url) => {
|
|
||||||
setAuthUrl(url)
|
|
||||||
})
|
|
||||||
|
|
||||||
setLoadingSpinnerDesc('encrypting content for DM')
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
reject(new Error('Timeout occurred'))
|
|
||||||
}, 15000) // timeout duration = 15 sec
|
|
||||||
})
|
|
||||||
|
|
||||||
const encrypted = await Promise.race([
|
|
||||||
nostrController.nip04Encrypt(pubkey, content),
|
|
||||||
timeoutPromise
|
|
||||||
])
|
|
||||||
.then((res) => {
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.log('err :>> ', err)
|
|
||||||
toast.error(
|
|
||||||
err.message || 'An error occurred while encrypting DM content'
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setAuthUrl(undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!encrypted) return
|
|
||||||
|
|
||||||
const event: EventTemplate = {
|
|
||||||
kind: 4,
|
|
||||||
content: encrypted,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [['p', signers[0]]]
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingSpinnerDesc('signing event for DM')
|
|
||||||
const signedEvent = await nostrController.signEvent(event).catch((err) => {
|
|
||||||
console.log('err :>> ', err)
|
|
||||||
toast.error(err.message || 'An error occurred while signing event for DM')
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!signedEvent) return
|
|
||||||
|
|
||||||
setLoadingSpinnerDesc('Publishing encrypted DM')
|
|
||||||
|
|
||||||
const metadataController = new MetadataController()
|
|
||||||
const relaySet = await metadataController
|
|
||||||
.findRelayListMetadata(pubkey)
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(
|
|
||||||
err.message || 'An error occurred while finding relay list metadata'
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!relaySet) return
|
|
||||||
|
|
||||||
// NOTE: according to Nip65
|
|
||||||
// DMs SHOULD only be broadcasted to the author's WRITE relays and to the receiver's READ relays to keep maximum privacy.
|
|
||||||
if (relaySet.read.length === 0) {
|
|
||||||
toast.error('No relay found for publishing encrypted DM')
|
|
||||||
}
|
|
||||||
|
|
||||||
await nostrController
|
|
||||||
.publishEvent(signedEvent, relaySet.read)
|
|
||||||
.then((relays) => {
|
|
||||||
toast.success(`Encrypted DM sent on: ${relays.join('\n')}`)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.log('err :>> ', err)
|
|
||||||
toast.error(err.message || 'An error occurred while publishing DM')
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authUrl) {
|
if (authUrl) {
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
|
261
src/pages/sign/index.tsx
Normal file
261
src/pages/sign/index.tsx
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import { Box, Button, Typography } from '@mui/material'
|
||||||
|
import { MuiFileInput } from 'mui-file-input'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import styles from './style.module.scss'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import {
|
||||||
|
encryptArrayBuffer,
|
||||||
|
generateEncryptionKey,
|
||||||
|
getHash,
|
||||||
|
parseJson,
|
||||||
|
readContentOfZipEntry,
|
||||||
|
sendDM,
|
||||||
|
signEventForMetaFile,
|
||||||
|
uploadToFileStorage
|
||||||
|
} from '../../utils'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
import { State } from '../../store/rootReducer'
|
||||||
|
import { NostrController } from '../../controllers'
|
||||||
|
|
||||||
|
export const SignDocument = () => {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||||
|
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||||
|
const [authUrl, setAuthUrl] = useState<string>()
|
||||||
|
|
||||||
|
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||||
|
|
||||||
|
const nostrController = NostrController.getInstance()
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsDraggingOver(false)
|
||||||
|
const file = event.dataTransfer.files[0]
|
||||||
|
if (file.type === 'application/zip') setSelectedFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsDraggingOver(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSign = async () => {
|
||||||
|
if (!selectedFile) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setLoadingSpinnerDesc('Parsing zip file')
|
||||||
|
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
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Parsing meta.json')
|
||||||
|
|
||||||
|
const metaFileContent = await readContentOfZipEntry(
|
||||||
|
zip,
|
||||||
|
'meta.json',
|
||||||
|
'string'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!metaFileContent) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await parseJson(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 (!meta) return
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Generating hashes for files')
|
||||||
|
|
||||||
|
const fileHashes: { [key: string]: string } = {}
|
||||||
|
const fileNames = Object.keys(meta.fileHashes)
|
||||||
|
|
||||||
|
// create 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 filePath = `files/${fileName}`
|
||||||
|
const arrayBuffer = await readContentOfZipEntry(
|
||||||
|
zip,
|
||||||
|
filePath,
|
||||||
|
'arraybuffer'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!arrayBuffer) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await getHash(arrayBuffer)
|
||||||
|
if (!hash) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileHashes[fileName] = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the current user is the last signer
|
||||||
|
const lastSignerIndex = meta.signers.length - 1
|
||||||
|
const signerIndex = meta.signers.indexOf(usersPubkey)
|
||||||
|
const isLastSigner = signerIndex === lastSignerIndex
|
||||||
|
|
||||||
|
// if current user is the last signer, then send DMs to all viewers
|
||||||
|
if (isLastSigner) {
|
||||||
|
for (const viewer of meta.viewers) {
|
||||||
|
await signAndSendToNext(zip, meta, viewer, fileHashes, false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const nextSigner = meta.signers[signerIndex + 1]
|
||||||
|
|
||||||
|
await signAndSendToNext(zip, meta, nextSigner, fileHashes, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const signAndSendToNext = async (
|
||||||
|
zip: JSZip,
|
||||||
|
meta: any,
|
||||||
|
receiver: string,
|
||||||
|
fileHashes: {
|
||||||
|
[key: string]: string
|
||||||
|
},
|
||||||
|
isNextSigner: boolean
|
||||||
|
) => {
|
||||||
|
setLoadingSpinnerDesc('Signing nostr event for meta file')
|
||||||
|
|
||||||
|
const signedEvent = await signEventForMetaFile(
|
||||||
|
receiver,
|
||||||
|
fileHashes,
|
||||||
|
nostrController,
|
||||||
|
setIsLoading
|
||||||
|
)
|
||||||
|
if (!signedEvent) return
|
||||||
|
|
||||||
|
meta.signedEvents = {
|
||||||
|
...meta.signedEvents,
|
||||||
|
[signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringifiedMeta = JSON.stringify(meta, null, 2)
|
||||||
|
zip.file('meta.json', stringifiedMeta)
|
||||||
|
|
||||||
|
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 encryptionKey = await generateEncryptionKey()
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc('Encrypting zip file')
|
||||||
|
const encryptedArrayBuffer = await encryptArrayBuffer(
|
||||||
|
arrayBuffer,
|
||||||
|
encryptionKey
|
||||||
|
)
|
||||||
|
|
||||||
|
const blob = new Blob([encryptedArrayBuffer])
|
||||||
|
|
||||||
|
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)
|
||||||
|
toast.error(err.message || 'Error occurred in uploading zip file')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fileUrl) return
|
||||||
|
|
||||||
|
setLoadingSpinnerDesc(
|
||||||
|
`Sending DM to next ${isNextSigner ? 'signer' : 'viewer'}`
|
||||||
|
)
|
||||||
|
await sendDM(
|
||||||
|
fileUrl,
|
||||||
|
encryptionKey,
|
||||||
|
receiver,
|
||||||
|
nostrController,
|
||||||
|
isNextSigner,
|
||||||
|
setAuthUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authUrl) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
title='Nsecbunker auth'
|
||||||
|
src={authUrl}
|
||||||
|
width='100%'
|
||||||
|
height='500px'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||||
|
<Box className={styles.container}>
|
||||||
|
<Typography component='label' variant='h6'>
|
||||||
|
Select decrypted zip file
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
className={styles.inputBlock}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
>
|
||||||
|
{isDraggingOver && (
|
||||||
|
<Box className={styles.fileDragOver}>
|
||||||
|
<Typography variant='body1'>Drop file here</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<MuiFileInput
|
||||||
|
placeholder='Drop file here, or click to select'
|
||||||
|
value={selectedFile}
|
||||||
|
onChange={(value) => setSelectedFile(value)}
|
||||||
|
InputProps={{
|
||||||
|
inputProps: {
|
||||||
|
accept: '.zip'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button onClick={handleSign} variant='contained'>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
27
src/pages/sign/style.module.scss
Normal file
27
src/pages/sign/style.module.scss
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
@import '../../colors.scss';
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: $text-color;
|
||||||
|
|
||||||
|
.inputBlock {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileDragOver {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
@ -4,10 +4,12 @@ import { LandingPage } from '../pages/landing/LandingPage'
|
|||||||
import { Login } from '../pages/login'
|
import { Login } from '../pages/login'
|
||||||
import { ProfilePage } from '../pages/profile'
|
import { ProfilePage } from '../pages/profile'
|
||||||
import { hexToNpub } from '../utils'
|
import { hexToNpub } from '../utils'
|
||||||
|
import { SignDocument } from '../pages/sign'
|
||||||
|
|
||||||
export const appPrivateRoutes = {
|
export const appPrivateRoutes = {
|
||||||
homePage: '/',
|
homePage: '/',
|
||||||
decryptZip: '/decrypt-zip'
|
decryptZip: '/decrypt-zip',
|
||||||
|
sign: '/sign'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appPublicRoutes = {
|
export const appPublicRoutes = {
|
||||||
@ -45,5 +47,9 @@ export const privateRoutes = [
|
|||||||
{
|
{
|
||||||
path: appPrivateRoutes.decryptZip,
|
path: appPrivateRoutes.decryptZip,
|
||||||
element: <DecryptZip />
|
element: <DecryptZip />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.sign,
|
||||||
|
element: <SignDocument />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from './nostr'
|
export * from './nostr'
|
||||||
export * from './profile'
|
export * from './profile'
|
||||||
|
export * from './zip'
|
||||||
|
13
src/types/zip.ts
Normal file
13
src/types/zip.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export interface OutputByType {
|
||||||
|
base64: string
|
||||||
|
string: string
|
||||||
|
text: string
|
||||||
|
binarystring: string
|
||||||
|
array: number[]
|
||||||
|
uint8array: Uint8Array
|
||||||
|
arraybuffer: ArrayBuffer
|
||||||
|
blob: Blob
|
||||||
|
nodebuffer: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutputType = keyof OutputByType
|
@ -1,16 +0,0 @@
|
|||||||
import { sha256 } from 'crypto-hash'
|
|
||||||
|
|
||||||
export const getFileHash = (file: File) => {
|
|
||||||
return new Promise<string>((resolve) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
|
|
||||||
reader.onload = async () => {
|
|
||||||
if (reader.result) {
|
|
||||||
const hash = await sha256(reader.result)
|
|
||||||
resolve(hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.readAsBinaryString(file)
|
|
||||||
})
|
|
||||||
}
|
|
19
src/utils/hash.ts
Normal file
19
src/utils/hash.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { sha256 } from 'crypto-hash'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the SHA-256 hash of an ArrayBuffer.
|
||||||
|
* @param arrayBuffer The ArrayBuffer to hash.
|
||||||
|
* @returns A Promise resolving to the SHA-256 hash as a hexadecimal string, or null if hashing fails.
|
||||||
|
*/
|
||||||
|
export const getHash = async (arrayBuffer: ArrayBuffer) => {
|
||||||
|
// Compute the SHA-256 hash of the array buffer
|
||||||
|
const hash = await sha256(arrayBuffer).catch((err) => {
|
||||||
|
// Handle error if hashing fails
|
||||||
|
console.log(`error occurred in hashing arrayBuffer :>> `, err)
|
||||||
|
toast.error(err.message || `error occurred in hashing arrayBuffer`)
|
||||||
|
return null // Return null if hashing fails
|
||||||
|
})
|
||||||
|
|
||||||
|
return hash // Return the SHA-256 hash
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
export * from './crypto'
|
export * from './crypto'
|
||||||
export * from './file'
|
export * from './hash'
|
||||||
export * from './localStorage'
|
export * from './localStorage'
|
||||||
|
export * from './misc'
|
||||||
export * from './nostr'
|
export * from './nostr'
|
||||||
export * from './string'
|
export * from './string'
|
||||||
|
export * from './zip'
|
||||||
|
196
src/utils/misc.ts
Normal file
196
src/utils/misc.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { EventTemplate } from 'nostr-tools'
|
||||||
|
import { MetadataController, NostrController } from '../controllers'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a file to a file storage service.
|
||||||
|
* @param blob The Blob object representing the file to upload.
|
||||||
|
* @param nostrController The NostrController instance for handling authentication.
|
||||||
|
* @returns The URL of the uploaded file.
|
||||||
|
*/
|
||||||
|
export const uploadToFileStorage = async (
|
||||||
|
blob: Blob,
|
||||||
|
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], `zipped-${unixNow}.zip`, {
|
||||||
|
type: 'application/zip'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define event metadata for authorization
|
||||||
|
const event: EventTemplate = {
|
||||||
|
kind: 24242,
|
||||||
|
content: 'Authorize Upload',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['t', 'upload'],
|
||||||
|
['expiration', String(unixNow + 60 * 5)], // Set expiration time to 5 minutes from now
|
||||||
|
['name', file.name],
|
||||||
|
['size', String(file.size)]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the authorization event using the NostrController
|
||||||
|
const authEvent = await nostrController.signEvent(event)
|
||||||
|
|
||||||
|
// URL of the file storage service
|
||||||
|
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
|
||||||
|
|
||||||
|
// Upload the file to the file storage service using Axios
|
||||||
|
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header
|
||||||
|
'Content-Type': 'application/zip' // Set content type header
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return the URL of the uploaded file
|
||||||
|
return response.data.url as string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a Direct Message (DM) to a recipient, encrypting the content and handling authentication.
|
||||||
|
* @param fileUrl The URL of the encrypted zip file to be included in the DM.
|
||||||
|
* @param encryptionKey The encryption key used to decrypt the zip file to be included in the DM.
|
||||||
|
* @param pubkey The public key of the recipient.
|
||||||
|
* @param nostrController The NostrController instance for handling authentication and encryption.
|
||||||
|
* @param isSigner Boolean indicating whether the recipient is a signer or viewer.
|
||||||
|
* @param setAuthUrl Function to set the authentication URL in the component state.
|
||||||
|
*/
|
||||||
|
export const sendDM = async (
|
||||||
|
fileUrl: string,
|
||||||
|
encryptionKey: string,
|
||||||
|
pubkey: string,
|
||||||
|
nostrController: NostrController,
|
||||||
|
isSigner: boolean,
|
||||||
|
setAuthUrl: (value: React.SetStateAction<string | undefined>) => void
|
||||||
|
) => {
|
||||||
|
// Construct the content of the DM
|
||||||
|
const initialLine = isSigner
|
||||||
|
? 'You have been requested for a signature.'
|
||||||
|
: 'You have received a signed document.'
|
||||||
|
const content = `${initialLine}\nHere is the URL for the zip file that you can download.\n${fileUrl}\nHowever, this zip file is encrypted and you need to decrypt it using https://app.sigit.io \nEncryption key: ${encryptionKey}`
|
||||||
|
|
||||||
|
// Set up event listener for authentication event
|
||||||
|
nostrController.on('nsecbunker-auth', (url) => {
|
||||||
|
setAuthUrl(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set up timeout promise to handle encryption timeout
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Timeout occurred'))
|
||||||
|
}, 15000) // Timeout duration = 15 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Encrypt the DM content, with timeout
|
||||||
|
const encrypted = await Promise.race([
|
||||||
|
nostrController.nip04Encrypt(pubkey, content),
|
||||||
|
timeoutPromise
|
||||||
|
])
|
||||||
|
.then((res) => {
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('err :>> ', err)
|
||||||
|
toast.error(
|
||||||
|
err.message || 'An error occurred while encrypting DM content'
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setAuthUrl(undefined) // Clear authentication URL
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return if encryption failed
|
||||||
|
if (!encrypted) return
|
||||||
|
|
||||||
|
// Construct event metadata for the DM
|
||||||
|
const event: EventTemplate = {
|
||||||
|
kind: 4, // DM event type
|
||||||
|
content: encrypted, // Encrypted DM content
|
||||||
|
created_at: Math.floor(Date.now() / 1000), // Current timestamp
|
||||||
|
tags: [['p', pubkey]] // Tag with recipient's public key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the DM event
|
||||||
|
const signedEvent = await nostrController.signEvent(event).catch((err) => {
|
||||||
|
console.log('err :>> ', err)
|
||||||
|
toast.error(err.message || 'An error occurred while signing event for DM')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return if event signing failed
|
||||||
|
if (!signedEvent) return
|
||||||
|
|
||||||
|
// Get relay list metadata
|
||||||
|
const metadataController = new MetadataController()
|
||||||
|
const relaySet = await metadataController
|
||||||
|
.findRelayListMetadata(pubkey)
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
err.message || 'An error occurred while finding relay list metadata'
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return if metadata retrieval failed
|
||||||
|
if (!relaySet) return
|
||||||
|
|
||||||
|
// Ensure relay list is not empty
|
||||||
|
if (relaySet.read.length === 0) {
|
||||||
|
toast.error('No relay found for publishing encrypted DM')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish the signed DM event to the recipient's read relays
|
||||||
|
await nostrController
|
||||||
|
.publishEvent(signedEvent, relaySet.read)
|
||||||
|
.then((relays) => {
|
||||||
|
toast.success(`Encrypted DM sent on: ${relays.join('\n')}`)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log('err :>> ', err)
|
||||||
|
toast.error(err.message || 'An error occurred while publishing DM')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an event for a meta.json file.
|
||||||
|
* @param receiver The recipient's public key.
|
||||||
|
* @param fileHashes Object containing file hashes.
|
||||||
|
* @param nostrController The NostrController instance for signing the event.
|
||||||
|
* @param setIsLoading Function to set loading state in the component.
|
||||||
|
* @returns A Promise resolving to the signed event, or null if signing fails.
|
||||||
|
*/
|
||||||
|
export const signEventForMetaFile = async (
|
||||||
|
receiver: string,
|
||||||
|
fileHashes: {
|
||||||
|
[key: string]: string
|
||||||
|
},
|
||||||
|
nostrController: NostrController,
|
||||||
|
setIsLoading: (value: React.SetStateAction<boolean>) => void
|
||||||
|
) => {
|
||||||
|
// Construct the event metadata for the meta file
|
||||||
|
const event: EventTemplate = {
|
||||||
|
kind: 1, // Event type for meta file
|
||||||
|
tags: [['r', receiver]], // Tag with recipient's public key
|
||||||
|
content: JSON.stringify(fileHashes), // Convert file hashes to JSON string
|
||||||
|
created_at: Math.floor(Date.now() / 1000) // Current timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
const signedEvent = await nostrController.signEvent(event).catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
toast.error(err.message || 'Error occurred in signing nostr event')
|
||||||
|
setIsLoading(false) // Set loading state to false
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
return signedEvent // Return the signed event
|
||||||
|
}
|
@ -59,3 +59,22 @@ export const hexStringToUint8Array = (hexString: string) => {
|
|||||||
// Return the resulting Uint8Array
|
// Return the resulting Uint8Array
|
||||||
return uint8Array
|
return uint8Array
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses JSON content asynchronously.
|
||||||
|
* @param content The JSON content to parse.
|
||||||
|
* @returns A Promise that resolves to the parsed JSON object.
|
||||||
|
*/
|
||||||
|
export const parseJson = (content: string): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
// Attempt to parse the JSON content
|
||||||
|
const json = JSON.parse(content)
|
||||||
|
// Resolve the promise with the parsed JSON object
|
||||||
|
resolve(json)
|
||||||
|
} catch (error) {
|
||||||
|
// If parsing fails, reject the promise with the error
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
32
src/utils/zip.ts
Normal file
32
src/utils/zip.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import JSZip from 'jszip'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { OutputByType, OutputType } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the content of a file within a zip archive.
|
||||||
|
* @param zip The JSZip object representing the zip archive.
|
||||||
|
* @param filePath The path of the file within the zip archive.
|
||||||
|
* @param outputType The type of output to return (e.g., 'string', 'arraybuffer', 'uint8array', etc.).
|
||||||
|
* @returns A Promise resolving to the content of the file, or null if an error occurs.
|
||||||
|
*/
|
||||||
|
export const readContentOfZipEntry = async <T extends OutputType>(
|
||||||
|
zip: JSZip,
|
||||||
|
filePath: string,
|
||||||
|
outputType: T
|
||||||
|
): Promise<OutputByType[T] | null> => {
|
||||||
|
// Get the zip entry corresponding to the specified file path
|
||||||
|
const zipEntry = zip.files[filePath]
|
||||||
|
|
||||||
|
// Read the content of the zip entry asynchronously
|
||||||
|
const fileContent = await zipEntry.async(outputType).catch((err) => {
|
||||||
|
// Handle any errors that occur during the read operation
|
||||||
|
console.log(`Error reading content of ${filePath}:`, err)
|
||||||
|
toast.error(
|
||||||
|
err.message || `Error occurred in reading content of ${filePath}`
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return the file content or null if an error occurred
|
||||||
|
return fileContent
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user