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}
|
||||
component={Link}
|
||||
/>
|
||||
<Tab
|
||||
label='Sign Document'
|
||||
value={appPrivateRoutes.sign}
|
||||
to={appPrivateRoutes.sign}
|
||||
component={Link}
|
||||
/>
|
||||
</Tabs>
|
||||
)}
|
||||
</Box>
|
||||
@ -201,6 +207,18 @@ export const AppBar = () => {
|
||||
Decrypt Zip
|
||||
</Button>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem sx={{ justifyContent: 'center' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
to={appPrivateRoutes.sign}
|
||||
onClick={handleCloseNavMenu}
|
||||
variant='contained'
|
||||
color='primary'
|
||||
>
|
||||
Sign Document
|
||||
</Button>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
|
@ -13,10 +13,8 @@ import {
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import axios from 'axios'
|
||||
import JSZip from 'jszip'
|
||||
import { MuiFileInput } from 'mui-file-input'
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
@ -30,10 +28,13 @@ import { ProfileMetadata } from '../../types'
|
||||
import {
|
||||
encryptArrayBuffer,
|
||||
generateEncryptionKey,
|
||||
getFileHash,
|
||||
getHash,
|
||||
pubToHex,
|
||||
queryNip05,
|
||||
shorten
|
||||
sendDM,
|
||||
shorten,
|
||||
signEventForMetaFile,
|
||||
uploadToFileStorage
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
@ -162,7 +163,25 @@ export const HomePage = () => {
|
||||
const fileHashes: { [key: string]: string } = {}
|
||||
|
||||
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
|
||||
}
|
||||
@ -173,20 +192,13 @@ export const HomePage = () => {
|
||||
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')
|
||||
const signedEvent = await nostrController.signEvent(event).catch((err) => {
|
||||
console.error(err)
|
||||
toast.error(err.message || 'Error occurred in signing nostr event')
|
||||
setIsLoading(false)
|
||||
return null
|
||||
})
|
||||
const signedEvent = await signEventForMetaFile(
|
||||
signers[0],
|
||||
fileHashes,
|
||||
nostrController,
|
||||
setIsLoading
|
||||
)
|
||||
|
||||
if (!signedEvent) return
|
||||
|
||||
@ -238,7 +250,7 @@ export const HomePage = () => {
|
||||
const blob = new Blob([encryptedArrayBuffer])
|
||||
|
||||
setLoadingSpinnerDesc('Uploading zip file to file storage.')
|
||||
const fileUrl = await uploadToFileStorage(blob)
|
||||
const fileUrl = await uploadToFileStorage(blob, nostrController)
|
||||
.then((url) => {
|
||||
toast.success('zip file uploaded to file storage')
|
||||
return url
|
||||
@ -251,134 +263,19 @@ export const HomePage = () => {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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 { ProfilePage } from '../pages/profile'
|
||||
import { hexToNpub } from '../utils'
|
||||
import { SignDocument } from '../pages/sign'
|
||||
|
||||
export const appPrivateRoutes = {
|
||||
homePage: '/',
|
||||
decryptZip: '/decrypt-zip'
|
||||
decryptZip: '/decrypt-zip',
|
||||
sign: '/sign'
|
||||
}
|
||||
|
||||
export const appPublicRoutes = {
|
||||
@ -45,5 +47,9 @@ export const privateRoutes = [
|
||||
{
|
||||
path: appPrivateRoutes.decryptZip,
|
||||
element: <DecryptZip />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.sign,
|
||||
element: <SignDocument />
|
||||
}
|
||||
]
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './nostr'
|
||||
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 './file'
|
||||
export * from './hash'
|
||||
export * from './localStorage'
|
||||
export * from './misc'
|
||||
export * from './nostr'
|
||||
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 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