Merge pull request 'Offline flow separation' (#304) from 231-offline-flow into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m45s

Reviewed-on: #304
Reviewed-by: eugene <eugene@nostrdev.com>
This commit is contained in:
b 2025-01-22 13:05:30 +00:00
commit feea3197d0
18 changed files with 845 additions and 723 deletions

View File

@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react'
import styles from './style.module.scss'
interface ButtonUnderlineProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean | undefined
}
export const ButtonUnderline = ({
onClick,
disabled = false,
children
}: PropsWithChildren<ButtonUnderlineProps>) => {
return (
<button
type="button"
className={styles.button}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}

View File

@ -0,0 +1,25 @@
@import '../../styles/colors.scss';
.button {
color: $primary-main !important;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: max-content;
margin: 0 auto;
// Override default styling
border: none !important;
outline: none !important;
// Override leaky css in sign page
background: transparent !important;
&:focus,
&:hover {
text-decoration: underline;
text-decoration-color: inherit;
}
}

View File

@ -1,16 +1,20 @@
import React from 'react'
import { Button, Menu, MenuItem } from '@mui/material'
import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCheck,
faLock,
faTriangleExclamation
} from '@fortawesome/free-solid-svg-icons'
import { CurrentUserFile } from '../../types/file.ts' import { CurrentUserFile } from '../../types/file.ts'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Button, Menu, MenuItem } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state'
import React from 'react'
interface FileListProps { interface FileListProps {
files: CurrentUserFile[] files: CurrentUserFile[]
currentFile: CurrentUserFile currentFile: CurrentUserFile
setCurrentFile: (file: CurrentUserFile) => void setCurrentFile: (file: CurrentUserFile) => void
handleExport: () => void handleExport?: () => void
handleEncryptedExport?: () => void handleEncryptedExport?: () => void
} }
@ -45,34 +49,48 @@ const FileList = ({
))} ))}
</ul> </ul>
<PopupState variant="popover" popupId="download-popup-menu"> {(typeof handleExport === 'function' ||
{(popupState) => ( typeof handleEncryptedExport === 'function') && (
<React.Fragment> <PopupState variant="popover" popupId="download-popup-menu">
<Button variant="contained" {...bindTrigger(popupState)}> {(popupState) => (
Export files <React.Fragment>
</Button> <Button variant="contained" {...bindTrigger(popupState)}>
<Menu {...bindMenu(popupState)}> Export files
<MenuItem </Button>
onClick={() => { <Menu {...bindMenu(popupState)}>
popupState.close {typeof handleEncryptedExport === 'function' && (
handleExport() <MenuItem
}} onClick={() => {
> popupState.close
Export Files handleEncryptedExport()
</MenuItem> }}
<MenuItem >
onClick={() => { <FontAwesomeIcon
popupState.close color={'var(--mui-palette-primary-main)'}
typeof handleEncryptedExport === 'function' && icon={faLock}
handleEncryptedExport() />
}} &nbsp; ENCRYPTED
> </MenuItem>
Export Encrypted Files )}
</MenuItem> {typeof handleExport === 'function' && (
</Menu> <MenuItem
</React.Fragment> onClick={() => {
)} popupState.close
</PopupState> handleExport()
}}
>
<FontAwesomeIcon
color={'var(--mui-palette-primary-main)'}
icon={faTriangleExclamation}
/>
&nbsp; UNENCRYPTED
</MenuItem>
)}
</Menu>
</React.Fragment>
)}
</PopupState>
)}
</div> </div>
) )
} }

View File

@ -8,15 +8,19 @@ import {
import React, { useState } from 'react' import React, { useState } from 'react'
import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx' import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons' import { faCheck, faDownload } from '@fortawesome/free-solid-svg-icons'
import { Button } from '@mui/material' import { Button } from '@mui/material'
import styles from './style.module.scss' import styles from './style.module.scss'
import { ButtonUnderline } from '../ButtonUnderline/index.tsx'
interface MarkFormFieldProps { interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (value: string) => void handleSelectedMarkValueChange: (value: string) => void
handleSubmit: (event: React.MouseEvent<HTMLButtonElement>) => void handleSubmit: (
event: React.MouseEvent<HTMLButtonElement>,
type: 'online' | 'offline'
) => void
selectedMark: CurrentUserMark | null selectedMark: CurrentUserMark | null
selectedMarkValue: string selectedMarkValue: string
} }
@ -73,11 +77,11 @@ const MarkFormField = ({
setComplete(true) setComplete(true)
} }
const handleSignAndComplete = ( const handleSignAndComplete =
event: React.MouseEvent<HTMLButtonElement> (type: 'online' | 'offline') =>
) => { (event: React.MouseEvent<HTMLButtonElement>) => {
handleSubmit(event) handleSubmit(event, type)
} }
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -129,18 +133,28 @@ const MarkFormField = ({
</div> </div>
</form> </form>
) : ( ) : (
<div className={styles.actionsBottom}> <>
<Button <div className={styles.actionsBottom}>
onClick={handleSignAndComplete} <Button
className={[styles.submitButton, styles.completeButton].join( onClick={handleSignAndComplete('online')}
' ' className={[
)} styles.submitButton,
styles.completeButton
].join(' ')}
disabled={!isReadyToSign()}
autoFocus
>
SIGN AND BROADCAST
</Button>
</div>
<ButtonUnderline
onClick={handleSignAndComplete('offline')}
disabled={!isReadyToSign()} disabled={!isReadyToSign()}
autoFocus
> >
SIGN AND COMPLETE <FontAwesomeIcon icon={faDownload} />
</Button> Sign and export locally instead
</div> </ButtonUnderline>
</>
)} )}
<div className={styles.footerContainer}> <div className={styles.footerContainer}>

View File

@ -3,7 +3,6 @@ import {
decryptArrayBuffer, decryptArrayBuffer,
encryptArrayBuffer, encryptArrayBuffer,
getHash, getHash,
isOnline,
uploadToFileStorage uploadToFileStorage
} from '../../../utils' } from '../../../utils'
import { MarkStrategy } from '../MarkStrategy' import { MarkStrategy } from '../MarkStrategy'
@ -37,21 +36,17 @@ export const SignatureStrategy: MarkStrategy = {
// Create the encrypted json file from array buffer and hash // Create the encrypted json file from array buffer and hash
const file = new File([encryptedArrayBuffer], `${hash}.json`) const file = new File([encryptedArrayBuffer], `${hash}.json`)
if (await isOnline()) { try {
try { const url = await uploadToFileStorage(file)
const url = await uploadToFileStorage(file) console.info(`${file.name} uploaded to file storage`)
console.info(`${file.name} uploaded to file storage`) return url
return url } catch (error) {
} catch (error) { if (error instanceof Error) {
if (error instanceof Error) { console.error(
console.error( `Error occurred in uploading file ${file.name}`,
`Error occurred in uploading file ${file.name}`, error.message
error.message )
)
}
} }
} else {
// TOOD: offline
} }
return value return value
@ -89,7 +84,6 @@ export const SignatureStrategy: MarkStrategy = {
return json return json
} }
// TOOD: offline
return value return value
} }
} }

View File

@ -24,9 +24,8 @@ import {
interface PdfMarkingProps { interface PdfMarkingProps {
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
files: CurrentUserFile[] files: CurrentUserFile[]
handleExport: () => void
handleEncryptedExport: () => void
handleSign: () => void handleSign: () => void
handleSignOffline: () => void
meta: Meta | null meta: Meta | null
otherUserMarks: Mark[] otherUserMarks: Mark[]
setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void
@ -39,18 +38,16 @@ interface PdfMarkingProps {
* @param props * @param props
* @constructor * @constructor
*/ */
const PdfMarking = (props: PdfMarkingProps) => { const PdfMarking = ({
const { files,
files, currentUserMarks,
currentUserMarks, setCurrentUserMarks,
setCurrentUserMarks, setUpdatedMarks,
setUpdatedMarks, handleSign,
handleExport, handleSignOffline,
handleEncryptedExport, meta,
handleSign, otherUserMarks
meta, }: PdfMarkingProps) => {
otherUserMarks
} = props
const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null) const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null)
const [selectedMarkValue, setSelectedMarkValue] = useState<string>('') const [selectedMarkValue, setSelectedMarkValue] = useState<string>('')
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null) const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
@ -99,7 +96,10 @@ const PdfMarking = (props: PdfMarkingProps) => {
/** /**
* Sign and Complete * Sign and Complete
*/ */
const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => { const handleSubmit = (
event: React.MouseEvent<HTMLButtonElement>,
type: 'online' | 'offline'
) => {
event.preventDefault() event.preventDefault()
if (selectedMarkValue && selectedMark) { if (selectedMarkValue && selectedMark) {
const updatedMark: CurrentUserMark = getUpdatedMark( const updatedMark: CurrentUserMark = getUpdatedMark(
@ -117,16 +117,10 @@ const PdfMarking = (props: PdfMarkingProps) => {
setUpdatedMarks(updatedMark.mark) setUpdatedMarks(updatedMark.mark)
} }
handleSign() if (type === 'online') handleSign()
else if (type === 'offline') handleSignOffline()
} }
// const updateCurrentUserMarkValues = () => {
// const updatedMark: CurrentUserMark = getUpdatedMark(selectedMark!, selectedMarkValue)
// const updatedCurrentUserMarks = updateCurrentUserMarks(currentUserMarks, updatedMark)
// setSelectedMarkValue(EMPTY)
// setCurrentUserMarks(updatedCurrentUserMarks)
// }
const handleChange = (value: string) => { const handleChange = (value: string) => {
setSelectedMarkValue(value) setSelectedMarkValue(value)
} }
@ -142,8 +136,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
files={files} files={files}
currentFile={currentFile} currentFile={currentFile}
setCurrentFile={setCurrentFile} setCurrentFile={setCurrentFile}
handleExport={handleExport}
handleEncryptedExport={handleEncryptedExport}
/> />
)} )}
</div> </div>

View File

@ -1,11 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import { DocSignatureEvent, Meta, SignedEventContent, FlatMeta } from '../types'
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent,
OpenTimestamp
} from '../types'
import { Mark } from '../types/mark' import { Mark } from '../types/mark'
import { import {
fromUnixTimestamp, fromUnixTimestamp,
@ -17,53 +11,11 @@ import {
} from '../utils' } from '../utils'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
import { Event } from 'nostr-tools'
import store from '../store/store' import store from '../store/store'
import { NostrController } from '../controllers' import { NostrController } from '../controllers'
import { MetaParseError } from '../types/errors/MetaParseError' import { MetaParseError } from '../types/errors/MetaParseError'
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy' import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy'
/**
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
*/
export interface FlatMeta
extends Meta,
CreateSignatureEventContent,
Partial<Omit<Event, 'pubkey' | 'created_at'>> {
// Remove pubkey and use submittedBy as `npub1${string}`
submittedBy?: `npub1${string}`
// Optional field only present on exported sigits
// Exporting adds user's pubkey
exportedBy?: `npub1${string}`
// Remove created_at and replace with createdAt
createdAt?: number
// Validated create signature event
isValid: boolean
// Decryption
encryptionKey: string | undefined
// Parsed Document Signatures
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time
completedAt?: number
// Calculated status fields
signedStatus: SigitStatus
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
timestamps?: OpenTimestamp[]
}
/** /**
* Custom use hook for parsing the Sigit Meta * Custom use hook for parsing the Sigit Meta
* @param meta Sigit Meta * @param meta Sigit Meta
@ -74,8 +26,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [kind, setKind] = useState<number>() const [kind, setKind] = useState<number>()
const [tags, setTags] = useState<string[][]>() const [tags, setTags] = useState<string[][]>()
const [createdAt, setCreatedAt] = useState<number>() const [createdAt, setCreatedAt] = useState<number>()
const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event const [submittedBy, setSubmittedBy] = useState<string>() // submittedBy, pubkey from nostr event (hex)
const [exportedBy, setExportedBy] = useState<`npub1${string}`>() // pubkey from export signature nostr event const [exportedBy, setExportedBy] = useState<string>() // pubkey from export signature nostr event (hex)
const [id, setId] = useState<string>() const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>() const [sig, setSig] = useState<string>()
@ -108,18 +60,16 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
;(async function () { ;(async function () {
try { try {
if (meta.exportSignature) { if (meta.exportSignature) {
const exportSignatureEvent = await parseNostrEvent( const exportSignatureEvent = parseNostrEvent(meta.exportSignature)
meta.exportSignature
)
if ( if (
verifyEvent(exportSignatureEvent) && verifyEvent(exportSignatureEvent) &&
exportSignatureEvent.pubkey exportSignatureEvent.pubkey
) { ) {
setExportedBy(exportSignatureEvent.pubkey as `npub1${string}`) setExportedBy(exportSignatureEvent.pubkey)
} }
} }
const createSignatureEvent = await parseNostrEvent(meta.createSignature) const createSignatureEvent = parseNostrEvent(meta.createSignature)
const { kind, tags, created_at, pubkey, id, sig, content } = const { kind, tags, created_at, pubkey, id, sig, content } =
createSignatureEvent createSignatureEvent
@ -129,12 +79,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setTags(tags) setTags(tags)
// created_at in nostr events are stored in seconds // created_at in nostr events are stored in seconds
setCreatedAt(fromUnixTimestamp(created_at)) setCreatedAt(fromUnixTimestamp(created_at))
setSubmittedBy(pubkey as `npub1${string}`) setSubmittedBy(pubkey)
setId(id) setId(id)
setSig(sig) setSig(sig)
const { title, signers, viewers, fileHashes, markConfig, zipUrl } = const { title, signers, viewers, fileHashes, markConfig, zipUrl } =
await parseCreateSignatureEventContent(content) parseCreateSignatureEventContent(content)
setTitle(title) setTitle(title)
setSigners(signers) setSigners(signers)

View File

@ -8,7 +8,6 @@ import {
Tooltip Tooltip
} from '@mui/material' } from '@mui/material'
import type { Identifier, XYCoord } from 'dnd-core' import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd' import { DndProvider, useDrag, useDrop } from 'react-dnd'
@ -39,7 +38,6 @@ import {
generateKeysFile, generateKeysFile,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline,
unixNow, unixNow,
npubToHex, npubToHex,
queryNip05, queryNip05,
@ -57,6 +55,7 @@ import { Mark } from '../../types/mark.ts'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { import {
faDownload,
faEllipsis, faEllipsis,
faEye, faEye,
faFile, faFile,
@ -79,6 +78,7 @@ import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks/useNDKContext.ts' import { useNDKContext } from '../../hooks/useNDKContext.ts'
import { useNDK } from '../../hooks/useNDK.ts' import { useNDK } from '../../hooks/useNDK.ts'
import { useImmer } from 'use-immer' import { useImmer } from 'use-immer'
import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx'
type FoundUser = NostrEvent & { npub: string } type FoundUser = NostrEvent & { npub: string }
@ -692,10 +692,18 @@ export const CreatePage = () => {
type: 'application/sigit' type: 'application/sigit'
}) })
const firstSigner = users.filter((user) => user.role === UserRole.signer)[0] const userSet = new Set<string>()
const nostrController = NostrController.getInstance()
const pubkey = await nostrController.capturePublicKey()
userSet.add(pubkey)
signers.forEach((signer) => {
userSet.add(signer.pubkey)
})
viewers.forEach((viewer) => {
userSet.add(viewer.pubkey)
})
const keysFileContent = await generateKeysFile( const keysFileContent = await generateKeysFile(
[firstSigner.pubkey], Array.from(userSet),
encryptionKey encryptionKey
) )
if (!keysFileContent) return null if (!keysFileContent) return null
@ -747,30 +755,6 @@ export const CreatePage = () => {
.catch(handleUploadError) .catch(handleUploadError)
} }
// Manage offline scenarios for signing or viewing the file
const handleOfflineFlow = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
) => {
const finalZipFile = await createFinalZipFile(
encryptedArrayBuffer,
encryptionKey
)
if (!finalZipFile) {
setIsLoading(false)
return
}
saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`)
// If user is the next signer, we can navigate directly to sign page
if (signers[0].pubkey === usersPubkey) {
navigate(appPrivateRoutes.sign, { state: { uploadedZip: finalZipFile } })
}
setIsLoading(false)
}
const generateFilesZip = async (): Promise<ArrayBuffer | null> => { const generateFilesZip = async (): Promise<ArrayBuffer | null> => {
const zip = new JSZip() const zip = new JSZip()
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
@ -836,7 +820,7 @@ export const CreatePage = () => {
return e.id return e.id
} }
const handleCreate = async () => { const initCreation = async () => {
try { try {
if (!validateInputs()) return if (!validateInputs()) return
@ -848,132 +832,183 @@ export const CreatePage = () => {
setLoadingSpinnerDesc('Generating encryption key') setLoadingSpinnerDesc('Generating encryption key')
const encryptionKey = await generateEncryptionKey() const encryptionKey = await generateEncryptionKey()
if (await isOnline()) { setLoadingSpinnerDesc('Creating marks')
setLoadingSpinnerDesc('generating files.zip') const markConfig = createMarks(fileHashes)
const arrayBuffer = await generateFilesZip()
if (!arrayBuffer) return
setLoadingSpinnerDesc('Encrypting files.zip') return {
const encryptedArrayBuffer = await encryptZipFile( encryptionKey,
arrayBuffer, markConfig,
encryptionKey fileHashes
) }
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
}
}
const markConfig = createMarks(fileHashes) const handleCreate = async () => {
try {
const result = await initCreation()
if (!result) return
setLoadingSpinnerDesc('Uploading files.zip to file storage') const { encryptionKey, markConfig, fileHashes } = result
const fileUrl = await uploadFile(encryptedArrayBuffer)
if (!fileUrl) return
setLoadingSpinnerDesc('Generating create signature') setLoadingSpinnerDesc('generating files.zip')
const createSignature = await generateCreateSignature( const arrayBuffer = await generateFilesZip()
markConfig, if (!arrayBuffer) return
fileHashes,
fileUrl
)
if (!createSignature) return
setLoadingSpinnerDesc('Generating keys for decryption') setLoadingSpinnerDesc('Encrypting files.zip')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
)
// generate key pairs for decryption setLoadingSpinnerDesc('Uploading files.zip to file storage')
const pubkeys = users.map((user) => user.pubkey) const fileUrl = await uploadFile(encryptedArrayBuffer)
// also add creator in the list if (!fileUrl) return
if (pubkeys.includes(usersPubkey!)) {
pubkeys.push(usersPubkey!)
}
const keys = await generateKeys(pubkeys, encryptionKey) setLoadingSpinnerDesc('Generating create signature')
if (!keys) return const createSignature = await generateCreateSignature(
markConfig,
fileHashes,
fileUrl
)
if (!createSignature) return
setLoadingSpinnerDesc('Generating an open timestamp.') setLoadingSpinnerDesc('Generating keys for decryption')
const timestamp = await generateTimestamp( // generate key pairs for decryption
extractNostrId(createSignature) const pubkeys = users.map((user) => user.pubkey)
) // also add creator in the list
if (pubkeys.includes(usersPubkey!)) {
pubkeys.push(usersPubkey!)
}
const meta: Meta = { const keys = await generateKeys(pubkeys, encryptionKey)
createSignature, if (!keys) return
keys,
modifiedAt: unixNow(),
docSignatures: {}
}
if (timestamp) { setLoadingSpinnerDesc('Generating an open timestamp.')
meta.timestamps = [timestamp]
}
setLoadingSpinnerDesc('Updating user app data') const timestamp = await generateTimestamp(extractNostrId(createSignature))
const event = await updateUsersAppData([meta]) const meta: Meta = {
if (!event) return createSignature,
keys,
modifiedAt: unixNow(),
docSignatures: {}
}
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) if (timestamp) {
meta.timestamps = [timestamp]
}
setLoadingSpinnerDesc('Sending notifications to counterparties') setLoadingSpinnerDesc('Updating user app data')
const promises = sendNotifications({
metaUrl, const event = await updateUsersAppData([meta])
keys: meta.keys if (!event) return
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications({
metaUrl,
keys: meta.keys
})
await Promise.all(promises)
.then(() => {
toast.success('Notifications sent successfully')
})
.catch(() => {
toast.error('Failed to publish notifications')
}) })
await Promise.all(promises) const isFirstSigner = signers[0].pubkey === usersPubkey
.then(() => { if (isFirstSigner) {
toast.success('Notifications sent successfully') navigate(appPrivateRoutes.sign, { state: { meta } })
})
.catch(() => {
toast.error('Failed to publish notifications')
})
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, { state: { meta } })
} else {
const createSignatureJson = JSON.parse(createSignature)
navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`)
}
} else { } else {
const zip = new JSZip() const createSignatureJson = JSON.parse(createSignature)
navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`)
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
} finally {
setIsLoading(false)
}
}
selectedFiles.forEach((file) => { const handleCreateOffline = async () => {
zip.file(`files/${file.name}`, file) try {
const result = await initCreation()
if (!result) return
const { encryptionKey, markConfig, fileHashes } = result
const zip = new JSZip()
selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file)
})
setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature(
markConfig,
fileHashes,
''
)
if (!createSignature) return
const meta: Meta = {
createSignature,
modifiedAt: unixNow(),
docSignatures: {}
}
// add meta to zip
try {
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
} catch (err) {
console.error(err)
toast.error('An error occurred in converting meta json to string')
return null
}
const arrayBuffer = await generateZipFile(zip)
if (!arrayBuffer) return
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
)
const finalZipFile = await createFinalZipFile(
encryptedArrayBuffer,
encryptionKey
)
if (!finalZipFile) {
setIsLoading(false)
return
}
// If user is the next signer, we can navigate directly to sign page
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, {
state: { arrayBuffer }
})
} else {
navigate(appPublicRoutes.verify, {
state: { uploadedZip: arrayBuffer }
}) })
const markConfig = createMarks(fileHashes)
setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature(
markConfig,
fileHashes,
''
)
if (!createSignature) return
const meta: Meta = {
createSignature,
modifiedAt: unixNow(),
docSignatures: {}
}
// add meta to zip
try {
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
} catch (err) {
console.error(err)
toast.error('An error occurred in converting meta json to string')
return null
}
const arrayBuffer = await generateZipFile(zip)
if (!arrayBuffer) return
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
)
await handleOfflineFlow(encryptedArrayBuffer, encryptionKey)
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
@ -1231,6 +1266,11 @@ export const CreatePage = () => {
Publish Publish
</Button> </Button>
<ButtonUnderline onClick={handleCreateOffline}>
<FontAwesomeIcon icon={faDownload} />
Create and export locally
</ButtonUnderline>
{!!error && ( {!!error && (
<FormHelperText error={!!error}>{error}</FormHelperText> <FormHelperText error={!!error}>{error}</FormHelperText>
)} )}

View File

@ -1,10 +1,9 @@
import { Button, TextField } from '@mui/material' import { Button, TextField } from '@mui/material'
import JSZip from 'jszip'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { useAppSelector } from '../../hooks' import { useAppSelector } from '../../hooks'
import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { appPrivateRoutes } from '../../routes'
import { Meta } from '../../types' import { Meta } from '../../types'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSearch } from '@fortawesome/free-solid-svg-icons' import { faSearch } from '@fortawesome/free-solid-svg-icons'
@ -15,6 +14,7 @@ import { Container } from '../../components/Container'
import styles from './style.module.scss' import styles from './style.module.scss'
import { import {
extractSigitCardDisplayInfo, extractSigitCardDisplayInfo,
navigateFromZip,
SigitCardDisplayInfo, SigitCardDisplayInfo,
SigitStatus SigitStatus
} from '../../utils' } from '../../utils'
@ -56,6 +56,7 @@ export const HomePage = () => {
[key: string]: SigitCardDisplayInfo [key: string]: SigitCardDisplayInfo
}>({}) }>({})
const usersAppData = useAppSelector((state) => state.userAppData) const usersAppData = useAppSelector((state) => state.userAppData)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
useEffect(() => { useEffect(() => {
if (usersAppData?.sigits) { if (usersAppData?.sigits) {
@ -63,7 +64,7 @@ export const HomePage = () => {
const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {} const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {}
for (const key in usersAppData.sigits) { for (const key in usersAppData.sigits) {
if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) { if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) {
const sigitInfo = await extractSigitCardDisplayInfo( const sigitInfo = extractSigitCardDisplayInfo(
usersAppData.sigits[key] usersAppData.sigits[key]
) )
if (sigitInfo) { if (sigitInfo) {
@ -92,27 +93,12 @@ export const HomePage = () => {
const fileName = file.name const fileName = file.name
const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters
if (fileExtension === '.sigit.zip') { if (fileExtension === '.sigit.zip') {
const zip = await JSZip.loadAsync(file).catch((err) => { const nav = await navigateFromZip(
console.log('err in loading zip file :>> ', err) file,
toast.error(err.message || 'An error occurred in loading zip file.') usersPubkey as `npub1${string}`
return null )
})
if (!zip) return if (nav) return navigate(nav.to, nav.options)
// 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 SiGit zip file') toast.error('Invalid SiGit zip file')
return return
@ -124,7 +110,7 @@ export const HomePage = () => {
state: { uploadedFiles: acceptedFiles } state: { uploadedFiles: acceptedFiles }
}) })
}, },
[navigate] [navigate, usersPubkey]
) )
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({

View File

@ -1,54 +1,41 @@
import axios from 'axios' import axios from 'axios'
import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import _ from 'lodash' import _ from 'lodash'
import { Event, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useAppSelector } from '../../hooks' import { useAppSelector } from '../../hooks'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { appPublicRoutes } from '../../routes'
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
import { import {
ARRAY_BUFFER,
decryptArrayBuffer, decryptArrayBuffer,
DEFLATE,
encryptArrayBuffer,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey, extractZipUrlAndEncryptionKey,
filterMarksByPubkey, filterMarksByPubkey,
findOtherUserMarks, findOtherUserMarks,
generateEncryptionKey,
generateKeysFile,
getCurrentUserFiles, getCurrentUserFiles,
getCurrentUserMarks, getCurrentUserMarks,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline,
loadZip, loadZip,
npubToHex, npubToHex,
parseJson, parseJson,
processMarks, encryptAndUploadMarks,
readContentOfZipEntry, readContentOfZipEntry,
signEventForMetaFile, signEventForMetaFile,
timeout,
unixNow, unixNow,
updateMarks, updateMarks,
uploadMetaToFileStorage uploadMetaToFileStorage
} from '../../utils' } from '../../utils'
import { CurrentUserMark, Mark } from '../../types/mark.ts' import { CurrentUserMark, Mark } from '../../types/mark.ts'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx' import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
import { import { convertToSigitFile, SigitFile } from '../../utils/file.ts'
convertToSigitFile,
getZipWithFiles,
SigitFile
} from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
import { useNDK } from '../../hooks/useNDK.ts' import { useNDK } from '../../hooks/useNDK.ts'
import { getLastSignersSig } from '../../utils/sign.ts'
export const SignPage = () => { export const SignPage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -81,12 +68,10 @@ export const SignPage = () => {
/** /**
* Received from `location.state` * Received from `location.state`
* *
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json
* arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode * arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode
*/ */
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || { const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
decryptedArrayBuffer: undefined, decryptedArrayBuffer: undefined
uploadedZip: undefined
} }
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
@ -218,63 +203,6 @@ export const SignPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta, usersPubkey]) }, [meta, usersPubkey])
const decrypt = useCallback(
async (file: File) => {
setLoadingSpinnerDesc('Decrypting file')
const zip = await loadZip(file)
if (!zip) return
const parsedKeysJson = await parseKeysJson(zip)
if (!parsedKeysJson) return
const encryptedArrayBuffer = await readContentOfZipEntry(
zip,
'compressed.sigit',
'arraybuffer'
)
if (!encryptedArrayBuffer) return
const { keys, sender } = parsedKeysJson
for (const key of keys) {
// decrypt the encryptionKey, with timeout (duration = 60 seconds)
const encryptionKey = await Promise.race([
nostrController.nip04Decrypt(sender, key),
timeout(60000)
])
.then((res) => {
return res
})
.catch((err) => {
console.log('err :>> ', err)
return null
})
// Return if encryption failed
if (!encryptionKey) continue
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
)
.catch((err) => {
console.log('err in decryption:>> ', err)
return null
})
.finally(() => {
setIsLoading(false)
})
if (arrayBuffer) return arrayBuffer
}
return null
},
[nostrController]
)
useEffect(() => { useEffect(() => {
if (metaInNavState) { if (metaInNavState) {
const processSigit = async () => { const processSigit = async () => {
@ -316,28 +244,14 @@ export const SignPage = () => {
}, []) }, [])
useEffect(() => { useEffect(() => {
// online mode - from create and home page views if (decryptedArrayBuffer || uploadedZip) {
handleDecryptedArrayBuffer(decryptedArrayBuffer || uploadedZip).finally(
if (decryptedArrayBuffer) { () => setIsLoading(false)
handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() =>
setIsLoading(false)
) )
} else if (uploadedZip) {
decrypt(uploadedZip)
.then((arrayBuffer) => {
if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer)
})
.catch((err) => {
console.error(`error occurred in decryption`, err)
toast.error(err.message || `error occurred in decryption`)
})
.finally(() => {
setIsLoading(false)
})
} else { } else {
setIsLoading(false) setIsLoading(false)
} }
}, [decryptedArrayBuffer, uploadedZip, decrypt]) }, [decryptedArrayBuffer, uploadedZip])
const handleArrayBufferFromBlossom = async ( const handleArrayBufferFromBlossom = async (
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
@ -396,30 +310,12 @@ export const SignPage = () => {
setMarks(updatedMarks) setMarks(updatedMarks)
} }
const parseKeysJson = async (zip: JSZip) => { const handleDecryptedArrayBuffer = async (
const keysFileContent = await readContentOfZipEntry( decryptedArrayBuffer: ArrayBuffer
zip, ) => {
'keys.json',
'string'
)
if (!keysFileContent) return null
return await parseJson<{ sender: string; keys: string[] }>(
keysFileContent
).catch((err) => {
console.log(`Error parsing content of keys.json:`, err)
toast.error(err.message || `Error parsing content of keys.json`)
return null
})
}
const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => {
const decryptedZipFile = new File([arrayBuffer], 'decrypted.zip')
setLoadingSpinnerDesc('Parsing zip file') setLoadingSpinnerDesc('Parsing zip file')
const zip = await loadZip(decryptedZipFile) const zip = await loadZip(decryptedArrayBuffer)
if (!zip) return if (!zip) return
const files: { [filename: string]: SigitFile } = {} const files: { [filename: string]: SigitFile } = {}
@ -479,16 +375,15 @@ export const SignPage = () => {
setMeta(parsedMetaJson) setMeta(parsedMetaJson)
} }
const handleSign = async () => { const initializeSigning = async (type: 'online' | 'offline') => {
if (Object.entries(files).length === 0 || !meta) return if (Object.entries(files).length === 0 || !meta) return
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
const usersNpub = hexToNpub(usersPubkey!) const usersNpub = hexToNpub(usersPubkey!)
const prevSig = getPrevSignersSig(usersNpub) const prevSig = getPrevSignersSig(usersNpub)
if (!prevSig) { if (!prevSig) {
setIsLoading(false)
toast.error('Previous signature is invalid') toast.error('Previous signature is invalid')
return return
} }
@ -508,7 +403,10 @@ export const SignPage = () => {
}) })
} }
const processedMarks = await processMarks(marks, encryptionKey) const processedMarks =
type === 'online'
? await encryptAndUploadMarks(marks, encryptionKey)
: marks
const signedEvent = await signEventForMeta({ const signedEvent = await signEventForMeta({
prevSig, prevSig,
@ -519,6 +417,22 @@ export const SignPage = () => {
const updatedMeta = updateMetaSignatures(meta, signedEvent) const updatedMeta = updateMetaSignatures(meta, signedEvent)
return {
encryptionKey,
updatedMeta,
signedEvent
}
}
const handleSign = async () => {
const result = await initializeSigning('online')
if (!result) {
setIsLoading(false)
return
}
const { encryptionKey, updatedMeta, signedEvent } = result
setLoadingSpinnerDesc('Generating an open timestamp.') setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(signedEvent.id) const timestamp = await generateTimestamp(signedEvent.id)
@ -527,19 +441,62 @@ export const SignPage = () => {
updatedMeta.modifiedAt = unixNow() updatedMeta.modifiedAt = unixNow()
} }
if (await isOnline()) { await handleOnlineFlow(updatedMeta, encryptionKey)
await handleOnlineFlow(updatedMeta, encryptionKey)
} else { const createSignature = JSON.parse(updatedMeta.createSignature)
setMeta(updatedMeta) navigate(`${appPublicRoutes.verify}/${createSignature.id}`)
}
const handleSignOffline = async () => {
const result = await initializeSigning('offline')
if (!result) {
setIsLoading(false) setIsLoading(false)
return
} }
if (metaInNavState) { const { updatedMeta } = result
const createSignature = JSON.parse(metaInNavState.createSignature)
navigate(`${appPublicRoutes.verify}/${createSignature.id}`) const zip = new JSZip()
} else { for (const [filename, value] of Object.entries(files)) {
navigate(appPrivateRoutes.homePage) zip.file(`files/${filename}`, await value.arrayBuffer())
} }
const stringifiedMeta = JSON.stringify(updatedMeta, null, 2)
zip.file('meta.json', stringifiedMeta)
// Handle errors during zip file generation
const handleZipError = (err: unknown) => {
console.log('Error in zip:>> ', err)
setIsLoading(false)
if (err instanceof Error) {
toast.error(err.message || 'Error occurred in generating zip file')
}
return null
}
setLoadingSpinnerDesc('Generating zip file')
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
if (!arrayBuffer) {
setIsLoading(false)
return
}
// Create a File object with the Blob data
const blob = new Blob([arrayBuffer])
const file = new File([blob], `request-${unixNow()}.sigit.zip`, {
type: 'application/zip'
})
setIsLoading(false)
navigate(`${appPublicRoutes.verify}`, { state: { uploadedZip: file } })
} }
// Sign the event for the meta file // Sign the event for the meta file
@ -570,66 +527,6 @@ export const SignPage = () => {
return metaCopy return metaCopy
} }
// create final zip file
const createFinalZipFile = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
): Promise<File | null> => {
// Get the current timestamp in seconds
const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed.sigit`, {
type: 'application/sigit'
})
const isLastSigner = checkIsLastSigner(signers)
const userSet = new Set<string>()
if (isLastSigner) {
if (submittedBy) {
userSet.add(submittedBy)
}
signers.forEach((signer) => {
userSet.add(npubToHex(signer)!)
})
viewers.forEach((viewer) => {
userSet.add(npubToHex(viewer)!)
})
} else {
const usersNpub = hexToNpub(usersPubkey!)
const signerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[signerIndex + 1]
userSet.add(npubToHex(nextSigner)!)
}
const keysFileContent = await generateKeysFile(
Array.from(userSet),
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
return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, {
type: 'application/zip'
})
}
// Check if the current user is the last signer // Check if the current user is the last signer
const checkIsLastSigner = (signers: string[]): boolean => { const checkIsLastSigner = (signers: string[]): boolean => {
const usersNpub = hexToNpub(usersPubkey!) const usersNpub = hexToNpub(usersPubkey!)
@ -638,16 +535,6 @@ export const SignPage = () => {
return signerIndex === lastSignerIndex return signerIndex === lastSignerIndex
} }
// Handle errors during zip file generation
const handleZipError = (err: unknown) => {
console.log('Error in zip:>> ', err)
setIsLoading(false)
if (err instanceof Error) {
toast.error(err.message || 'Error occurred in generating zip file')
}
return null
}
// Handle the online flow: update users app data and send notifications // Handle the online flow: update users app data and send notifications
const handleOnlineFlow = async ( const handleOnlineFlow = async (
meta: Meta, meta: Meta,
@ -718,99 +605,6 @@ export const SignPage = () => {
setIsLoading(false) setIsLoading(false)
} }
const handleExport = async () => {
const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
navigate(appPublicRoutes.verify)
}
const handleEncryptedExport = async () => {
const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const prepareZipExport = async (): Promise<ArrayBuffer | null> => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey)
return Promise.resolve(null)
const usersNpub = hexToNpub(usersPubkey)
if (
!signers.includes(usersNpub) &&
!viewers.includes(usersNpub) &&
submittedBy !== usersNpub
)
return Promise.resolve(null)
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return Promise.resolve(null)
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return Promise.resolve(null)
const signedEvent = await signEventForMetaFile(
JSON.stringify({
prevSig
}),
nostrController,
setIsLoading
)
if (!signedEvent) return Promise.resolve(null)
const exportSignature = JSON.stringify(signedEvent, null, 2)
const stringifiedMeta = JSON.stringify(
{
...meta,
exportSignature
},
null,
2
)
const zip = await getZipWithFiles(meta, files)
zip.file('meta.json', stringifiedMeta)
const arrayBuffer = await zip
.generateAsync({
type: ARRAY_BUFFER,
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 Promise.resolve(null)
return Promise.resolve(arrayBuffer)
}
/** /**
* This function accepts an npub of a signer and return the signature of its previous signer. * This function accepts an npub of a signer and return the signature of its previous signer.
* This prevSig will be used in the content of the provided signer's signedEvent * This prevSig will be used in the content of the provided signer's signedEvent
@ -855,8 +649,7 @@ export const SignPage = () => {
setCurrentUserMarks={setCurrentUserMarks} setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks} setUpdatedMarks={setUpdatedMarks}
handleSign={handleSign} handleSign={handleSign}
handleExport={handleExport} handleSignOffline={handleSignOffline}
handleEncryptedExport={handleEncryptedExport}
otherUserMarks={otherUserMarks} otherUserMarks={otherUserMarks}
meta={meta} meta={meta}
/> />

View File

@ -1,7 +1,7 @@
import { Box, Button, Typography } from '@mui/material' import { Box, Button, Typography } from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
@ -27,14 +27,14 @@ import {
generateKeysFile, generateKeysFile,
ARRAY_BUFFER, ARRAY_BUFFER,
DEFLATE, DEFLATE,
uploadMetaToFileStorage uploadMetaToFileStorage,
decrypt
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { useLocation, useParams } from 'react-router-dom' import { useLocation, useParams } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useAppSelector, useNDK } from '../../hooks' import { useAppSelector, useNDK } from '../../hooks'
import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
@ -60,6 +60,7 @@ import {
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts' import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
import _ from 'lodash' import _ from 'lodash'
import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx' import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx'
import { SignerService } from '../../services/index.ts'
interface PdfViewProps { interface PdfViewProps {
files: CurrentUserFile[] files: CurrentUserFile[]
@ -188,7 +189,7 @@ export const VerifyPage = () => {
* meta will be received in navigation from create & home page in online mode * meta will be received in navigation from create & home page in online mode
*/ */
let metaInNavState = location?.state?.meta || undefined let metaInNavState = location?.state?.meta || undefined
const { uploadedZip } = location.state || {} const uploadedZip = location?.state?.uploadedZip || undefined
const [selectedFile, setSelectedFile] = useState<File | null>(null) const [selectedFile, setSelectedFile] = useState<File | null>(null)
/** /**
@ -208,12 +209,6 @@ export const VerifyPage = () => {
} }
} }
useEffect(() => {
if (uploadedZip) {
setSelectedFile(uploadedZip)
}
}, [uploadedZip])
const [meta, setMeta] = useState<Meta>(metaInNavState) const [meta, setMeta] = useState<Meta>(metaInNavState)
const { const {
@ -483,17 +478,35 @@ export const VerifyPage = () => {
} }
}, [encryptionKey, metaInNavState, zipUrl]) }, [encryptionKey, metaInNavState, zipUrl])
const handleVerify = async () => { const handleVerify = useCallback(async (selectedFile: File) => {
if (!selectedFile) return
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Loading zip file')
const zip = await JSZip.loadAsync(selectedFile).catch((err) => { let zip = await JSZip.loadAsync(selectedFile).catch((err) => {
console.log('err in loading zip file :>> ', err) console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.') toast.error(err.message || 'An error occurred in loading zip file.')
return null return null
}) })
if (!zip) return if (!zip) {
return setIsLoading(false)
}
if ('keys.json' in zip.files) {
// Decrypt
setLoadingSpinnerDesc('Decrypting zip file content')
const arrayBuffer = await decrypt(selectedFile).catch((err) => {
console.error(`error occurred in decryption`, err)
toast.error(err.message || `error occurred in decryption`)
})
if (arrayBuffer) {
// Replace the zip and continue processing
zip = await JSZip.loadAsync(arrayBuffer)
}
}
setLoadingSpinnerDesc('Opening zip file content')
const files: { [filename: string]: SigitFile } = {} const files: { [filename: string]: SigitFile } = {}
const fileHashes: { [key: string]: string | null } = {} const fileHashes: { [key: string]: string | null } = {}
@ -550,12 +563,21 @@ export const VerifyPage = () => {
} }
) )
if (!parsedMetaJson) return if (!parsedMetaJson) {
setIsLoading(false)
return
}
setMeta(parsedMetaJson) setMeta(parsedMetaJson)
setIsLoading(false) setIsLoading(false)
} }, [])
useEffect(() => {
if (uploadedZip) {
handleVerify(uploadedZip)
}
}, [handleVerify, uploadedZip])
// Handle errors during zip file generation // Handle errors during zip file generation
const handleZipError = (err: unknown) => { const handleZipError = (err: unknown) => {
@ -567,14 +589,6 @@ export const VerifyPage = () => {
return null return null
} }
// Check if the current user is the last signer
const checkIsLastSigner = (signers: string[]): boolean => {
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub)
return signerIndex === lastSignerIndex
}
// create final zip file // create final zip file
const createFinalZipFile = async ( const createFinalZipFile = async (
encryptedArrayBuffer: ArrayBuffer, encryptedArrayBuffer: ArrayBuffer,
@ -587,28 +601,16 @@ export const VerifyPage = () => {
type: 'application/sigit' type: 'application/sigit'
}) })
const isLastSigner = checkIsLastSigner(signers)
const userSet = new Set<string>() const userSet = new Set<string>()
if (submittedBy) {
if (isLastSigner) { userSet.add(submittedBy)
if (submittedBy) {
userSet.add(submittedBy)
}
signers.forEach((signer) => {
userSet.add(npubToHex(signer)!)
})
viewers.forEach((viewer) => {
userSet.add(npubToHex(viewer)!)
})
} else {
const usersNpub = hexToNpub(usersPubkey!)
const signerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[signerIndex + 1]
userSet.add(npubToHex(nextSigner)!)
} }
signers.forEach((signer) => {
userSet.add(npubToHex(signer)!)
})
viewers.forEach((viewer) => {
userSet.add(npubToHex(viewer)!)
})
const keysFileContent = await generateKeysFile( const keysFileContent = await generateKeysFile(
Array.from(userSet), Array.from(userSet),
@ -637,7 +639,10 @@ export const VerifyPage = () => {
const handleExport = async () => { const handleExport = async () => {
const arrayBuffer = await prepareZipExport() const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return if (!arrayBuffer) {
setIsLoading(false)
return
}
const blob = new Blob([arrayBuffer]) const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`) saveAs(blob, `exported-${unixNow()}.sigit.zip`)
@ -647,7 +652,10 @@ export const VerifyPage = () => {
const handleEncryptedExport = async () => { const handleEncryptedExport = async () => {
const arrayBuffer = await prepareZipExport() const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return if (!arrayBuffer) {
setIsLoading(false)
return
}
const key = await generateEncryptionKey() const key = await generateEncryptionKey()
@ -656,7 +664,11 @@ export const VerifyPage = () => {
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key) const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return if (!finalZipFile) {
setIsLoading(false)
return
}
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`) saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false) setIsLoading(false)
@ -678,7 +690,11 @@ export const VerifyPage = () => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getLastSignersSig(meta, signers) if (!meta) return Promise.resolve(null)
const signerService = new SignerService(meta)
const prevSig = signerService.getLastSignerSig()
if (!prevSig) return Promise.resolve(null) if (!prevSig) return Promise.resolve(null)
const signedEvent = await signEventForMetaFile( const signedEvent = await signEventForMetaFile(
@ -739,7 +755,10 @@ export const VerifyPage = () => {
{selectedFile && ( {selectedFile && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}> <Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleVerify} variant="contained"> <Button
onClick={() => handleVerify(selectedFile)}
variant="contained"
>
Verify Verify
</Button> </Button>
</Box> </Box>

View File

@ -1 +1,2 @@
export * from './cache' export * from './cache'
export * from './signer'

View File

@ -0,0 +1,143 @@
import { toast } from 'react-toastify'
import { Meta, SignedEventContent } from '../../types'
import {
parseCreateSignatureEventContent,
parseNostrEvent,
SigitStatus,
SignStatus
} from '../../utils'
import { MetaParseError } from '../../types/errors/MetaParseError'
import { verifyEvent } from 'nostr-tools'
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
export class SignerService {
#signers: `npub1${string}`[] = []
#nextSigner: `npub1${string}` | undefined
#signatures = new Map<`npub1${string}`, string>()
#signersStatus = new Map<`npub1${string}`, SignStatus>()
#lastSignerSig: string | undefined
constructor(source: Meta) {
this.#process(source.createSignature, source.docSignatures)
}
getNextSigner = () => {
return this.#nextSigner
}
isNextSigner = (npub: `npub1${string}`) => {
return this.#nextSigner === npub
}
isLastSigner = (npub: `npub1${string}`) => {
const lastIndex = this.#signers.length - 1
const npubIndex = this.#signers.indexOf(npub)
return npubIndex === lastIndex
}
#isFullySigned = () => {
const signedBy = Object.keys(this.#signatures) as `npub1${string}`[]
const isCompletelySigned = this.#signers.every((signer) =>
signedBy.includes(signer)
)
return isCompletelySigned
}
getSignedStatus = () => {
return this.#isFullySigned() ? SigitStatus.Complete : SigitStatus.Partial
}
getSignerStatus = (npub: `npub1${string}`) => {
return this.#signersStatus.get(npub)
}
getNavigate = (npub: `npub1${string}`) => {
return this.isNextSigner(npub)
? appPrivateRoutes.sign
: appPublicRoutes.verify
}
getLastSignerSig = () => {
return this.#lastSignerSig
}
#process = (
createSignature: string,
docSignatures: { [key: `npub1${string}`]: string }
) => {
try {
const createSignatureEvent = parseNostrEvent(createSignature)
const { signers } = parseCreateSignatureEventContent(
createSignatureEvent.content
)
const getPrevSignerSig = (npub: `npub1${string}`) => {
if (signers[0] === npub) {
return createSignatureEvent.sig
}
// Find the index of signer
const currentSignerIndex = signers.findIndex(
(signer) => signer === npub
)
// Return if could not found user in signer's list
if (currentSignerIndex === -1) return
// Find prev signer
const prevSigner = signers[currentSignerIndex - 1]
// Get the signature of prev signer
return this.#signatures.get(prevSigner)
}
this.#signers = [...signers]
for (const npub in docSignatures) {
try {
// Parse each signature event
const event = parseNostrEvent(docSignatures[npub as `npub1${string}`])
this.#signatures.set(npub as `npub1${string}`, event.sig)
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
const prevSignersSig = getPrevSignerSig(npub as `npub1${string}`)
const signedEvent: SignedEventContent = JSON.parse(event.content)
if (
signedEvent.prevSig &&
prevSignersSig &&
signedEvent.prevSig === prevSignersSig
) {
this.#signersStatus.set(
npub as `npub1${string}`,
SignStatus.Signed
)
this.#lastSignerSig = event.sig
}
} else {
this.#signersStatus.set(
npub as `npub1${string}`,
SignStatus.Invalid
)
}
} catch (error) {
this.#signersStatus.set(npub as `npub1${string}`, SignStatus.Invalid)
}
}
this.#signers
.filter((s) => !this.#signatures.has(s))
.forEach((s) => this.#signersStatus.set(s, SignStatus.Pending))
// Get the first signer that hasn't signed
const nextSigner = this.#signers.find((s) => !this.#signatures.has(s))
if (nextSigner) {
this.#nextSigner = nextSigner
this.#signersStatus.set(nextSigner, SignStatus.Awaiting)
}
} catch (error) {
if (error instanceof MetaParseError) {
toast.error(error.message)
console.error(error.name, error.message, error.cause, error.context)
} else {
console.error('Unexpected error', error)
}
}
}
}

View File

@ -1,6 +1,7 @@
import { Mark } from './mark' import { Mark } from './mark'
import { Keys } from '../store/auth/types' import { Keys } from '../store/auth/types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { SigitStatus, SignStatus } from '../utils'
export enum UserRole { export enum UserRole {
signer = 'Signer', signer = 'Signer',
@ -35,11 +36,6 @@ export interface SignedEventContent {
marks: Mark[] marks: Mark[]
} }
export interface Sigit {
fileUrl: string
meta: Meta
}
export interface OpenTimestamp { export interface OpenTimestamp {
nostrId: string nostrId: string
value: string value: string
@ -92,3 +88,43 @@ export interface SigitNotification {
export function isSigitNotification(obj: unknown): obj is SigitNotification { export function isSigitNotification(obj: unknown): obj is SigitNotification {
return typeof (obj as SigitNotification).metaUrl === 'string' return typeof (obj as SigitNotification).metaUrl === 'string'
} }
/**
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
*/
export interface FlatMeta
extends Meta,
CreateSignatureEventContent,
Partial<Omit<Event, 'pubkey' | 'created_at'>> {
submittedBy?: string
// Optional field only present on exported sigits
// Exporting adds user's pubkey
exportedBy?: string
// Remove created_at and replace with createdAt
createdAt?: number
// Validated create signature event
isValid: boolean
// Decryption
encryptionKey: string | undefined
// Parsed Document Signatures
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time
completedAt?: number
// Calculated status fields
signedStatus: SigitStatus
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
timestamps?: OpenTimestamp[]
}

View File

@ -270,21 +270,10 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => {
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
} }
export const getOptimizedPathsWithStrokeWidth = (svgString: string) => { export const encryptAndUploadMarks = async (
const parser = new DOMParser() marks: Mark[],
const xmlDoc = parser.parseFromString(svgString, 'image/svg+xml') encryptionKey?: string
const paths = xmlDoc.querySelectorAll('path') ) => {
const tuples: string[][] = []
paths.forEach((path) => {
const d = path.getAttribute('d') ?? ''
const strokeWidth = path.getAttribute('stroke-width') ?? ''
tuples.push([d, strokeWidth])
})
return tuples
}
export const processMarks = async (marks: Mark[], encryptionKey?: string) => {
const _marks = [...marks] const _marks = [...marks]
for (let i = 0; i < _marks.length; i++) { for (let i = 0; i < _marks.length; i++) {
const mark = _marks[i] const mark = _marks[i]

View File

@ -49,9 +49,9 @@ export interface SigitCardDisplayInfo {
* @param raw Raw string for parsing * @param raw Raw string for parsing
* @returns parsed Event * @returns parsed Event
*/ */
export const parseNostrEvent = async (raw: string): Promise<Event> => { export const parseNostrEvent = (raw: string): Event => {
try { try {
const event = await parseJson<Event>(raw) const event = JSON.parse(raw) as Event
return event return event
} catch (error) { } catch (error) {
throw new MetaParseError(MetaParseErrorType.PARSE_ERROR_EVENT, { throw new MetaParseError(MetaParseErrorType.PARSE_ERROR_EVENT, {
@ -66,12 +66,13 @@ export const parseNostrEvent = async (raw: string): Promise<Event> => {
* @param raw Raw string for parsing * @param raw Raw string for parsing
* @returns parsed CreateSignatureEventContent * @returns parsed CreateSignatureEventContent
*/ */
export const parseCreateSignatureEventContent = async ( export const parseCreateSignatureEventContent = (
raw: string raw: string
): Promise<CreateSignatureEventContent> => { ): CreateSignatureEventContent => {
try { try {
const createSignatureEventContent = const createSignatureEventContent = JSON.parse(
await parseJson<CreateSignatureEventContent>(raw) raw
) as CreateSignatureEventContent
return createSignatureEventContent return createSignatureEventContent
} catch (error) { } catch (error) {
throw new MetaParseError( throw new MetaParseError(
@ -89,7 +90,7 @@ export const parseCreateSignatureEventContent = async (
* @param meta Sigit metadata * @param meta Sigit metadata
* @returns SigitCardDisplayInfo * @returns SigitCardDisplayInfo
*/ */
export const extractSigitCardDisplayInfo = async (meta: Meta) => { export const extractSigitCardDisplayInfo = (meta: Meta) => {
if (!meta?.createSignature) return if (!meta?.createSignature) return
const sigitInfo: SigitCardDisplayInfo = { const sigitInfo: SigitCardDisplayInfo = {
@ -100,14 +101,14 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
} }
try { try {
const createSignatureEvent = await parseNostrEvent(meta.createSignature) const createSignatureEvent = parseNostrEvent(meta.createSignature)
sigitInfo.isValid = verifyEvent(createSignatureEvent) sigitInfo.isValid = verifyEvent(createSignatureEvent)
// created_at in nostr events are stored in seconds // created_at in nostr events are stored in seconds
sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at) sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at)
const createSignatureContent = await parseCreateSignatureEventContent( const createSignatureContent = parseCreateSignatureEventContent(
createSignatureEvent.content createSignatureEvent.content
) )

View File

@ -1,46 +1,11 @@
import { Event } from 'nostr-tools'
import { Meta } from '../types'
/**
* This function returns the signature of last signer
* It will be used in the content of export signature's signedEvent
*/
const getLastSignersSig = (
meta: Meta,
signers: `npub1${string}`[]
): string | null => {
// if there're no signers then use creator's signature
if (signers.length === 0) {
try {
const createSignatureEvent: Event = JSON.parse(meta.createSignature)
return createSignatureEvent.sig
} catch (error) {
return null
}
}
// get last signer
const lastSigner = signers[signers.length - 1]
// get the signature of last signer
try {
const lastSignatureEvent: Event = JSON.parse(meta.docSignatures[lastSigner])
return lastSignatureEvent.sig
} catch (error) {
return null
}
}
/** /**
* Checks if all signers have signed the sigit * Checks if all signers have signed the sigit
* @param signers - an array of npubs of all signers from the Sigit * @param signers - an array of npubs of all signers from the Sigit
* @param signedBy - an array of npubs that have signed it already * @param signedBy - an array of npubs that have signed it already
*/ */
const isFullySigned = ( export const isFullySigned = (
signers: `npub1${string}`[], signers: `npub1${string}`[],
signedBy: `npub1${string}`[] signedBy: `npub1${string}`[]
): boolean => { ): boolean => {
return signers.every((signer) => signedBy.includes(signer)) return signers.every((signer) => signedBy.includes(signer))
} }
export { getLastSignersSig, isFullySigned }

View File

@ -1,6 +1,12 @@
import JSZip from 'jszip' import JSZip from 'jszip'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { InputFileFormat, OutputByType, OutputType } from '../types' import { InputFileFormat, Meta, OutputByType, OutputType } from '../types'
import { NavigateOptions, To } from 'react-router-dom'
import { appPublicRoutes } from '../routes'
import { NostrController } from '../controllers'
import { decryptArrayBuffer } from './crypto'
import { hexToNpub, parseJson, SigitStatus, timeout } from '.'
import { SignerService } from '../services'
/** /**
* Read the content of a file within a zip archive. * Read the content of a file within a zip archive.
@ -9,7 +15,7 @@ import { InputFileFormat, OutputByType, OutputType } from '../types'
* @param outputType The type of output to return (e.g., 'string', 'arraybuffer', 'uint8array', etc.). * @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. * @returns A Promise resolving to the content of the file, or null if an error occurs.
*/ */
const readContentOfZipEntry = async <T extends OutputType>( export const readContentOfZipEntry = async <T extends OutputType>(
zip: JSZip, zip: JSZip,
filePath: string, filePath: string,
outputType: T outputType: T
@ -34,7 +40,7 @@ const readContentOfZipEntry = async <T extends OutputType>(
}) })
} }
const loadZip = async (data: InputFileFormat): Promise<JSZip | null> => { export const loadZip = async (data: InputFileFormat): Promise<JSZip | null> => {
try { try {
return await JSZip.loadAsync(data) return await JSZip.loadAsync(data)
} catch (err) { } catch (err) {
@ -46,4 +52,130 @@ const loadZip = async (data: InputFileFormat): Promise<JSZip | null> => {
} }
} }
export { readContentOfZipEntry, loadZip } export const decrypt = async (file: File) => {
const nostrController = NostrController.getInstance()
const zip = await loadZip(file)
if (!zip) return
const keysFileContent = await readContentOfZipEntry(
zip,
'keys.json',
'string'
)
if (!keysFileContent) return null
const parsedKeysJson = await parseJson<{ sender: string; keys: string[] }>(
keysFileContent
).catch((err) => {
console.log(`Error parsing content of keys.json:`, err)
toast.error(err.message || `Error parsing content of keys.json`)
return null
})
if (!parsedKeysJson) return
const encryptedArrayBuffer = await readContentOfZipEntry(
zip,
'compressed.sigit',
'arraybuffer'
)
if (!encryptedArrayBuffer) return
const { keys, sender } = parsedKeysJson
for (const key of keys) {
// decrypt the encryptionKey, with timeout (duration = 60 seconds)
const encryptionKey = await Promise.race([
nostrController.nip04Decrypt(sender, key),
timeout(60000)
])
.then((res) => {
return res
})
.catch((err) => {
console.log('err :>> ', err)
return null
})
// Return if encryption failed
if (!encryptionKey) continue
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
).catch((err) => {
console.log('err in decryption:>> ', err)
return null
})
if (arrayBuffer) return arrayBuffer
}
return null
}
type NavigateArgs = { to: To; options?: NavigateOptions }
export const navigateFromZip = async (file: File, pubkey: `npub1${string}`) => {
if (!file.name.endsWith('.sigit.zip')) {
toast.error(`Not a SiGit zip file: ${file.name}`)
}
try {
let zip = await JSZip.loadAsync(file)
if (!zip) {
return null
}
let arrayBuffer: ArrayBuffer | undefined
if ('keys.json' in zip.files) {
// Decrypt
const decryptedArrayBuffer = await decrypt(file).catch((err) => {
console.error(`error occurred in decryption`, err)
toast.error(err.message || `error occurred in decryption`)
})
if (decryptedArrayBuffer) {
// Replace the zip and continue processing
zip = await JSZip.loadAsync(decryptedArrayBuffer)
arrayBuffer = decryptedArrayBuffer
}
}
if ('meta.json' in zip.files) {
// Check where we need to navigate
// Find Meta and process it for signer state
const metaContent = await readContentOfZipEntry(
zip,
'meta.json',
'string'
)
if (metaContent) {
const meta = JSON.parse(metaContent) as Meta
const signerService = new SignerService(meta)
const to =
signerService.getSignedStatus() === SigitStatus.Complete
? appPublicRoutes.verify
: signerService.getNavigate(hexToNpub(pubkey))
return {
to,
options: {
state: { uploadedZip: arrayBuffer || file }
}
} as NavigateArgs
}
}
return null
} catch (err) {
console.error('err in processing sigit zip file :>> ', err)
if (err instanceof Error) {
toast.error(err.message || 'An error occurred in loading zip file.')
}
return null
}
}