chore(git): Merge branch 'staging' into issue-99

This commit is contained in:
Stixx 2024-06-29 00:49:05 +02:00
commit 007f49b671
11 changed files with 1362 additions and 752 deletions

View File

@ -29,4 +29,6 @@ jobs:
- name: Release Build
run: |
npm -g install cloudron-surfer
surfer put --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io dist/* /
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io
surfer put dist/* / --all -d
surfer put dist/.well-known / --all

View File

@ -29,4 +29,6 @@ jobs:
- name: Release Build
run: |
npm -g install cloudron-surfer
surfer put --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server staging.sigit.io dist/* /
surfer config --token ${{ secrets.STAGING_CLOUDRON_SURFER_TOKEN }} --server staging.sigit.io
surfer put dist/* / --all -d
surfer put dist/.well-known / --all

View File

@ -0,0 +1,15 @@
{
"names": {
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
},
"relays": {
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
"wss://brb.io",
"wss://nostr.v0l.io",
"wss://nostr.coinos.io",
"wss://rsslay.nostr.net",
"wss://relay.current.fyi",
"wss://nos.io"
]
}
}

View File

@ -378,6 +378,61 @@ export class NostrController extends EventEmitter {
throw new Error('Login method is undefined')
}
/**
* Decrypts a given content based on the current login method.
*
* @param sender - The sender's public key.
* @param content - The encrypted content to decrypt.
* @returns A promise that resolves to the decrypted content.
*/
nip04Decrypt = async (sender: string, content: string) => {
const loginMethod = (store.getState().auth as AuthState).loginMethod
if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
if (!nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const decrypted = await nostr.nip04.decrypt(sender, content)
return decrypted
}
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const decrypted = await nip04.decrypt(privateKey, sender, content)
return decrypted
}
if (loginMethod === LoginMethods.nsecBunker) {
const user = new NDKUser({ pubkey: sender })
this.remoteSigner?.on('authUrl', (authUrl) => {
this.emit('nsecbunker-auth', authUrl)
})
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
const decrypted = await this.remoteSigner.decrypt(user, content)
return decrypted
}
throw new Error('Login method is undefined')
}
/**
* Function will capture the public key from the nostr extension or if no extension present
* function wil capture the public key from the local storage

View File

@ -22,7 +22,7 @@ import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username'
@ -33,6 +33,7 @@ import { Meta, ProfileMetadata, User, UserRole } from '../../types'
import {
encryptArrayBuffer,
generateEncryptionKey,
generateKeysFile,
getHash,
hexToNpub,
isOnline,
@ -49,16 +50,16 @@ import { HTML5Backend } from 'react-dnd-html5-backend'
import type { Identifier, XYCoord } from 'dnd-core'
import { useDrag, useDrop } from 'react-dnd'
import saveAs from 'file-saver'
import CopyModal from '../../components/copyModal'
import { Event, kinds } from 'nostr-tools'
import { DrawPDFFields } from '../../components/DrawPDFFields'
export const CreatePage = () => {
const navigate = useNavigate()
const location = useLocation()
const { uploadedFile } = location.state || {}
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [openCopyModal, setOpenCopyModel] = useState(false)
const [textToCopy, setTextToCopy] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
@ -75,6 +76,12 @@ export const CreatePage = () => {
const nostrController = NostrController.getInstance()
useEffect(() => {
if (uploadedFile) {
setSelectedFiles([uploadedFile])
}
}, [uploadedFile])
useEffect(() => {
if (usersPubkey) {
setUsers((prev) => {
@ -222,57 +229,67 @@ export const CreatePage = () => {
)
}
const handleCreate = async () => {
// Validate inputs before proceeding
const validateInputs = (): boolean => {
if (!title.trim()) {
toast.error('Title can not be empty')
return
return false
}
if (users.length === 0) {
toast.error(
'No signer/viewer is provided. At least add one signer or viewer.'
'No signer/viewer is provided. At least add one signer or viewer.'
)
return
return false
}
if (selectedFiles.length === 0) {
toast.error('No file is selected. Select at least 1 file')
return
return false
}
setIsLoading(true)
setLoadingSpinnerDesc('Generating hashes for files')
return true
}
// Handle errors during file arrayBuffer conversion
const handleFileError = (file: File) => (err: any) => {
console.log(
`Error while getting arrayBuffer of file ${file.name} :>> `,
err
)
toast.error(
err.message || `Error while getting arrayBuffer of file ${file.name}`
)
return null
}
// Generate hash for each selected file
const generateFileHashes = async (): Promise<{
[key: string]: string
} | null> => {
const fileHashes: { [key: string]: string } = {}
// generating file hashes
for (const file of selectedFiles) {
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 arraybuffer = await file.arrayBuffer().catch(handleFileError(file))
if (!arraybuffer) return null
const hash = await getHash(arraybuffer)
if (!hash) {
setIsLoading(false)
return
return null
}
fileHashes[file.name] = hash
}
return fileHashes
}
// Create a zip file with the selected files and sign the event
const createZipFile = async (fileHashes: {
[key: string]: string
}): Promise<{ zip: JSZip; createSignature: string } | null> => {
const zip = new JSZip()
// zipping files
selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file)
})
@ -281,6 +298,7 @@ export const CreatePage = () => {
const viewers = users.filter((user) => user.role === UserRole.viewer)
setLoadingSpinnerDesc('Signing nostr event')
const createSignature = await signEventForMetaFile(
JSON.stringify({
signers: signers.map((signer) => hexToNpub(signer.pubkey)),
@ -291,12 +309,27 @@ export const CreatePage = () => {
setIsLoading
)
if (!createSignature) return
if (!createSignature) return null
try {
return {
zip,
createSignature: JSON.stringify(createSignature, null, 2)
}
} catch (error) {
return null
}
}
// Add metadata and file hashes to the zip file
const addMetaToZip = async (
zip: JSZip,
createSignature: string
): Promise<string | null> => {
// create content for meta file
const meta: Meta = {
title,
createSignature: JSON.stringify(createSignature, null, 2),
createSignature,
docSignatures: {}
}
@ -305,113 +338,221 @@ export const CreatePage = () => {
zip.file('meta.json', stringifiedMeta)
const metaHash = await getHash(stringifiedMeta)
if (!metaHash) return
if (!metaHash) return null
const metaHashJson = {
[usersPubkey!]: metaHash
}
zip.file('hashes.json', JSON.stringify(metaHashJson, null, 2))
return metaHash
} catch (err) {
console.error(err)
toast.error('An error occurred in converting meta json to string')
return
return null
}
}
// Handle errors during zip file generation
const handleZipError = (err: any) => {
console.log('Error in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
}
// Generate the zip file
const generateZipFile = async (zip: JSZip): Promise<ArrayBuffer | null> => {
setLoadingSpinnerDesc('Generating zip file')
const arraybuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
compressionOptions: { level: 6 }
})
.catch(handleZipError)
if (!arraybuffer) return
return arraybuffer
}
// Encrypt the zip file with the generated encryption key
const encryptZipFile = async (
arraybuffer: ArrayBuffer,
encryptionKey: string
): Promise<ArrayBuffer> => {
setLoadingSpinnerDesc('Encrypting zip file')
return encryptArrayBuffer(arraybuffer, encryptionKey)
}
// create final zip file
const createFinalZipFile = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
): Promise<File | null> => {
// Get the current timestamp in seconds
const unixNow = Math.floor(Date.now() / 1000)
const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed.sigit`, {
type: 'application/sigit'
})
const firstSigner = users.filter((user) => user.role === UserRole.signer)[0]
const keysFileContent = await generateKeysFile(
[firstSigner.pubkey],
encryptionKey
)
if (!keysFileContent) return null
const zip = new JSZip()
zip.file(`compressed.sigit`, file)
zip.file('keys.json', keysFileContent)
const arraybuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
if (!arraybuffer) return null
const finalZipFile = new File(
[new Blob([arraybuffer])],
`${unixNow}.sigit.zip`,
{
type: 'application/zip'
}
)
return finalZipFile
}
const handleOnlineFlow = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
) => {
const unixNow = Math.floor(Date.now() / 1000)
const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed-${unixNow}.sigit`, {
type: 'application/sigit'
})
const fileUrl = await uploadFile(file)
if (!fileUrl) return
await sendDMs(fileUrl, encryptionKey)
}
// Handle errors during file upload
const handleUploadError = (err: any) => {
console.log('Error in upload:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in uploading file')
return null
}
// Upload the file to the storage
const uploadFile = async (file: File): Promise<string | null> => {
setIsLoading(true)
setLoadingSpinnerDesc('Uploading sigit to file storage.')
const fileUrl = await uploadToFileStorage(file, nostrController)
.then((url) => {
toast.success('Sigit uploaded to file storage')
return url
})
.catch(handleUploadError)
return fileUrl
}
// Send DMs to signers and viewers with the file URL
const sendDMs = async (fileUrl: string, encryptionKey: string) => {
setLoadingSpinnerDesc('Sending DM to signers/viewers')
const signers = users.filter((user) => user.role === UserRole.signer)
const viewers = users.filter((user) => user.role === UserRole.viewer)
if (signers.length > 0) {
await sendDM(
fileUrl,
encryptionKey,
signers[0].pubkey,
nostrController,
true,
setAuthUrl
)
} else {
for (const viewer of viewers) {
await sendDM(
fileUrl,
encryptionKey,
viewer.pubkey,
nostrController,
false,
setAuthUrl
)
}
}
}
// Manage offline scenarios for signing or viewing the file
const handleOfflineFlow = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
) => {
const finalZipFile = await createFinalZipFile(
encryptedArrayBuffer,
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 encryptArrayBuffer(
arraybuffer,
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
).finally(() => setIsLoading(false))
const blob = new Blob([encryptedArrayBuffer])
)
if (await isOnline()) {
setIsLoading(true)
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob, nostrController)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (!fileUrl) return
setLoadingSpinnerDesc('Sending DM to signers/viewers')
// send DM to first signer if exists
if (signers.length > 0) {
await sendDM(
fileUrl,
encryptionKey,
signers[0].pubkey,
nostrController,
true,
setAuthUrl
)
} else {
// send DM to all viewers if no signer
for (const viewer of viewers) {
// todo: execute in parallel
await sendDM(
fileUrl,
encryptionKey,
viewer.pubkey,
nostrController,
false,
setAuthUrl
)
}
}
setIsLoading(false)
navigate(
`${appPrivateRoutes.sign}?file=${encodeURIComponent(
fileUrl
)}&key=${encodeURIComponent(encryptionKey)}`
)
await handleOnlineFlow(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 } })
} else {
saveAs(blob, 'request.sigit')
setTextToCopy(encryptionKey)
setOpenCopyModel(true)
}
await handleOfflineFlow(encryptedArrayBuffer, encryptionKey)
}
navigate(appPrivateRoutes.sign, { state: { arrayBuffer } })
}
if (authUrl) {
@ -512,15 +653,6 @@ export const CreatePage = () => {
</Button>
</Box>
</Box>
<CopyModal
open={openCopyModal}
handleClose={() => {
setOpenCopyModel(false)
navigate(appPrivateRoutes.sign)
}}
title="Decryption key for Sigit file"
textToCopy={textToCopy}
/>
</>
)
}

View File

@ -5,13 +5,63 @@ import {
PersonOutline,
Upload
} from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/material'
import { Box, Button, Tooltip, Typography } from '@mui/material'
import { useNavigate } from 'react-router-dom'
import { appPrivateRoutes } from '../../routes'
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import styles from './style.module.scss'
import { useRef } from 'react'
import JSZip from 'jszip'
import { toast } from 'react-toastify'
export const HomePage = () => {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0]
if (file) {
// Check if the file extension is .sigit.zip
const fileName = file.name
const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters
if (fileExtension === '.sigit.zip') {
const zip = await JSZip.loadAsync(file).catch((err) => {
console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.')
return null
})
if (!zip) return
// navigate to sign page if zip contains keys.json
if ('keys.json' in zip.files) {
return navigate(appPrivateRoutes.sign, {
state: { uploadedZip: file }
})
}
// navigate to verify page if zip contains meta.json
if ('meta.json' in zip.files) {
return navigate(appPublicRoutes.verify, {
state: { uploadedZip: file }
})
}
toast.error('Invalid zip file')
return
}
// navigate to create page
navigate(appPrivateRoutes.create, { state: { uploadedFile: file } })
}
}
return (
<Box className={styles.container}>
@ -19,11 +69,26 @@ export const HomePage = () => {
<Typography variant="h3" className={styles.title}>
Sigits
</Typography>
<Box className={styles.actionButtons}>
{/* This is for desktop view */}
<Box
className={styles.actionButtons}
sx={{
display: {
xs: 'none',
md: 'flex'
}
}}
>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<Button
variant="outlined"
startIcon={<Upload />}
onClick={() => navigate(appPrivateRoutes.sign)}
onClick={handleUploadClick}
>
Upload
</Button>
@ -35,45 +100,96 @@ export const HomePage = () => {
Create
</Button>
</Box>
{/* This is for mobile view */}
<Box
className={styles.actionButtons}
sx={{
display: {
xs: 'flex',
md: 'none'
}
}}
>
<Tooltip title="Upload" arrow>
<Button
variant="outlined"
onClick={() => navigate(appPrivateRoutes.sign)}
>
<Upload />
</Button>
</Tooltip>
<Tooltip title="Create" arrow>
<Button
variant="contained"
onClick={() => navigate(appPrivateRoutes.create)}
>
<Add />
</Button>
</Tooltip>
</Box>
</Box>
<Box className={styles.submissions}>
<PlaceHolder />
<PlaceHolder />
<PlaceHolder />
</Box>
<PlaceHolder />
<PlaceHolder />
<PlaceHolder />
</Box>
)
}
const PlaceHolder = () => {
return (
<Box className={styles.submissions}>
<Box className={styles.item}>
<Box className={styles.titleBox}>
<Typography variant="body1" className={styles.titleBoxItem}>
<Description />
Title
</Typography>
<Typography variant="body2" className={styles.titleBoxItem}>
<PersonOutline />
Sigit
</Typography>
<Typography variant="body2" className={styles.titleBoxItem}>
<CalendarMonth />
07 Jun 10:23 AM
<Box
className={styles.item}
sx={{
flexDirection: {
xs: 'column',
md: 'row'
}
}}
>
<Box
className={styles.titleBox}
sx={{
flexDirection: {
xs: 'row',
md: 'column'
},
borderBottomLeftRadius: {
xs: 'initial',
md: 'inherit'
},
borderTopRightRadius: {
xs: 'inherit',
md: 'initial'
}
}}
>
<Typography variant="body1" className={styles.titleBoxItem}>
<Description />
Title
</Typography>
<Typography variant="body2" className={styles.titleBoxItem}>
<PersonOutline />
Sigit
</Typography>
<Typography variant="body2" className={styles.titleBoxItem}>
<CalendarMonth />
07 Jun 10:23 AM
</Typography>
</Box>
<Box className={styles.signers}>
<Box className={styles.signerItem}>
<Typography variant="button" className={styles.status}>
Sent
</Typography>
<Typography variant="body1">placeholder@sigit.io</Typography>
</Box>
<Box className={styles.signers}>
<Box className={styles.signerItem}>
<Typography variant="button" className={styles.status}>
Sent
</Typography>
<Typography variant="body1">placeholder@sigit.io</Typography>
</Box>
<Box className={styles.signerItem}>
<Typography variant="button" className={styles.status}>
Awaiting
</Typography>
<Typography variant="body1">placeholder@sigit.io</Typography>
</Box>
<Box className={styles.signerItem}>
<Typography variant="button" className={styles.status}>
Awaiting
</Typography>
<Typography variant="body1">placeholder@sigit.io</Typography>
</Box>
</Box>
</Box>

View File

@ -15,7 +15,6 @@
}
.actionButtons {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
@ -25,6 +24,7 @@
.submissions {
display: flex;
flex-direction: column;
gap: 10px;
.item {
display: flex;
@ -33,10 +33,10 @@
.titleBox {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
padding: 10px;
background-color: #e7e2df99;
background-color: #cdc8c499;
border-top-left-radius: inherit;
border-bottom-left-radius: inherit;

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

View File

@ -1,5 +1,10 @@
import axios from 'axios'
import { EventTemplate } from 'nostr-tools'
import {
EventTemplate,
generateSecretKey,
getPublicKey,
nip04
} from 'nostr-tools'
import { MetadataController, NostrController } from '../controllers'
import { toast } from 'react-toastify'
import { appPrivateRoutes } from '../routes'
@ -11,17 +16,12 @@ import { appPrivateRoutes } from '../routes'
* @returns The URL of the uploaded file.
*/
export const uploadToFileStorage = async (
blob: Blob,
file: File,
nostrController: NostrController
) => {
// Get the current timestamp in seconds
const unixNow = Math.floor(Date.now() / 1000)
// Create a File object with the Blob data
const file = new File([blob], `compressed-${unixNow}.sigit`, {
type: 'application/sigit'
})
// Define event metadata for authorization
const event: EventTemplate = {
kind: 24242,
@ -205,3 +205,52 @@ export const signEventForMetaFile = async (
return signedEvent // Return the signed event
}
/**
* Generates the content for keys.json file.
*
* @param users - An array of public keys.
* @param key - The key that will be encrypted for each user.
* @returns A promise that resolves to a JSON string containing the sender's public key and encrypted keys, or null if an error occurs.
*/
export const generateKeysFile = async (
users: string[],
key: string
): Promise<string | null> => {
// Generate a random private key to act as the sender
const privateKey = generateSecretKey()
// Calculate the required length to be a multiple of 10
const requiredLength = Math.ceil(users.length / 10) * 10
const additionalKeysCount = requiredLength - users.length
if (additionalKeysCount > 0) {
// generate random public keys to make the keys array multiple of 10
const additionalPubkeys = Array.from({ length: additionalKeysCount }, () =>
getPublicKey(generateSecretKey())
)
users.push(...additionalPubkeys)
}
// Encrypt the key for each user's public key
const promises = users.map((pubkey) => nip04.encrypt(privateKey, pubkey, key))
// Wait for all encryption promises to resolve
const keys = await Promise.all(promises).catch((err) => {
console.log('Error while generating keys :>> ', err)
toast.error(err.message || 'An error occurred while generating key')
return null
})
// If any encryption promise failed, return null
if (!keys) return null
try {
// Return a JSON string containing the sender's public key and encrypted keys
return JSON.stringify({ sender: getPublicKey(privateKey), keys })
} catch (error) {
// Return null if an error occurs during JSON stringification
return null
}
}