release #306

Merged
b merged 25 commits from staging into main 2025-01-23 18:54:21 +00:00
24 changed files with 920 additions and 760 deletions

View File

@ -6,19 +6,19 @@ Welcome to Sigit! We are thrilled that you are interested in contributing to thi
### Reporting Bugs ### Reporting Bugs
If you encounter a bug while using Sigit, please [open an issue](https://git.sigit.io/g/web/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug. If you encounter a bug while using Sigit, please [open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
### Suggesting Enhancements ### Suggesting Enhancements
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.sigit.io/g/web/issues/new) to suggest an enhancement. If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) to suggest an enhancement.
### Pull Requests ### Pull Requests
We welcome pull requests from contributors! To contribute code changes: We welcome pull requests from contributors! To contribute code changes:
1. Fork the repository and create your branch from `main`. 1. Fork the repository and create your branch from `staging`.
2. Make your changes and ensure they pass any existing tests. 2. Make your changes and ensure they pass any existing tests.
3. Write meaningful commit messages. 3. Write meaningful commit messages (conventional commit standard)
4. Submit a pull request, describing your changes in detail and referencing any related issues. 4. Submit a pull request, describing your changes in detail and referencing any related issues.
## Development Setup ## Development Setup
@ -35,4 +35,14 @@ All contributions, including pull requests, undergo code review. Code review ens
## Contact ## Contact
If you have questions or need further assistance, you can reach out to [maintainer's email]. If you have questions or need further assistance, you can reach out to `npub1d0csynrrxcynkcedktdzrdj6gnras2psg48mf46kxjazs8skrjgq9uzhlq`
## Testing
The following items should be tested with each release:
- Create a SIGit with at least 3 signers
- Create a SIGit where the creator is not the first signer
- Create a SIGit where one co-signer has no marks
- Create a SIGit using a file other than a PDF
- Use several login mechanisms, browsers, operating systems whilst testing

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,6 +49,8 @@ const FileList = ({
))} ))}
</ul> </ul>
{(typeof handleExport === 'function' ||
typeof handleEncryptedExport === 'function') && (
<PopupState variant="popover" popupId="download-popup-menu"> <PopupState variant="popover" popupId="download-popup-menu">
{(popupState) => ( {(popupState) => (
<React.Fragment> <React.Fragment>
@ -52,27 +58,39 @@ const FileList = ({
Export files Export files
</Button> </Button>
<Menu {...bindMenu(popupState)}> <Menu {...bindMenu(popupState)}>
{typeof handleEncryptedExport === 'function' && (
<MenuItem
onClick={() => {
popupState.close
handleEncryptedExport()
}}
>
<FontAwesomeIcon
color={'var(--mui-palette-primary-main)'}
icon={faLock}
/>
&nbsp; ENCRYPTED
</MenuItem>
)}
{typeof handleExport === 'function' && (
<MenuItem <MenuItem
onClick={() => { onClick={() => {
popupState.close popupState.close
handleExport() handleExport()
}} }}
> >
Export Files <FontAwesomeIcon
</MenuItem> color={'var(--mui-palette-primary-main)'}
<MenuItem icon={faTriangleExclamation}
onClick={() => { />
popupState.close &nbsp; UNENCRYPTED
typeof handleEncryptedExport === 'function' &&
handleEncryptedExport()
}}
>
Export Encrypted Files
</MenuItem> </MenuItem>
)}
</Menu> </Menu>
</React.Fragment> </React.Fragment>
)} )}
</PopupState> </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,10 +77,10 @@ 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 (
@ -129,18 +133,28 @@ const MarkFormField = ({
</div> </div>
</form> </form>
) : ( ) : (
<>
<div className={styles.actionsBottom}> <div className={styles.actionsBottom}>
<Button <Button
onClick={handleSignAndComplete} onClick={handleSignAndComplete('online')}
className={[styles.submitButton, styles.completeButton].join( className={[
' ' styles.submitButton,
)} styles.completeButton
].join(' ')}
disabled={!isReadyToSign()} disabled={!isReadyToSign()}
autoFocus autoFocus
> >
SIGN AND COMPLETE SIGN AND BROADCAST
</Button> </Button>
</div> </div>
<ButtonUnderline
onClick={handleSignAndComplete('offline')}
disabled={!isReadyToSign()}
>
<FontAwesomeIcon icon={faDownload} />
Sign and export locally instead
</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,7 +36,6 @@ 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`)
@ -50,9 +48,6 @@ export const SignatureStrategy: MarkStrategy = {
) )
} }
} }
} 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,
handleExport,
handleEncryptedExport,
handleSign, handleSign,
handleSignOffline,
meta, meta,
otherUserMarks otherUserMarks
} = props }: PdfMarkingProps) => {
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

@ -38,12 +38,16 @@ const PdfView = ({
currentUserMarks: CurrentUserMark[], currentUserMarks: CurrentUserMark[],
hash: string hash: string
): CurrentUserMark[] => { ): CurrentUserMark[] => {
return currentUserMarks.filter( return currentUserMarks.filter((currentUserMark) =>
(currentUserMark) => currentUserMark.mark.pdfFileHash === hash currentUserMark.mark.pdfFileHash
? currentUserMark.mark.pdfFileHash === hash
: currentUserMark.mark.fileHash === hash
) )
} }
const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => { const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => {
return marks.filter((mark) => mark.pdfFileHash === hash) return marks.filter((mark) =>
mark.pdfFileHash ? mark.pdfFileHash === hash : mark.fileHash === hash
)
} }
return ( return (
<div className="files-wrapper"> <div className="files-wrapper">

View File

@ -19,7 +19,7 @@
"page": 1 "page": 1
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
} }
], ],
"da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": [ "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": [
@ -34,7 +34,7 @@
"page": 2 "page": 2
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
} }
] ]
} }
@ -54,7 +54,7 @@
"page": 1 "page": 1
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"value": "Pera Peric" "value": "Pera Peric"
}, },
{ {
@ -68,7 +68,7 @@
"page": 2 "page": 2
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"value": "Pera Peric" "value": "Pera Peric"
} }
] ]

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 }
@ -636,8 +636,8 @@ export const CreatePage = () => {
width: drawnField.width width: drawnField.width
}, },
npub: drawnField.counterpart, npub: drawnField.counterpart,
pdfFileHash: fileHash, fileName: file.name,
fileName: file.name fileHash
} }
}) })
}) || [] }) || []
@ -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,7 +832,30 @@ 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')
const markConfig = createMarks(fileHashes)
return {
encryptionKey,
markConfig,
fileHashes
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
}
}
const handleCreate = async () => {
try {
const result = await initCreation()
if (!result) return
const { encryptionKey, markConfig, fileHashes } = result
setLoadingSpinnerDesc('generating files.zip') setLoadingSpinnerDesc('generating files.zip')
const arrayBuffer = await generateFilesZip() const arrayBuffer = await generateFilesZip()
if (!arrayBuffer) return if (!arrayBuffer) return
@ -859,8 +866,6 @@ export const CreatePage = () => {
encryptionKey encryptionKey
) )
const markConfig = createMarks(fileHashes)
setLoadingSpinnerDesc('Uploading files.zip to file storage') setLoadingSpinnerDesc('Uploading files.zip to file storage')
const fileUrl = await uploadFile(encryptedArrayBuffer) const fileUrl = await uploadFile(encryptedArrayBuffer)
if (!fileUrl) return if (!fileUrl) return
@ -887,9 +892,7 @@ export const CreatePage = () => {
setLoadingSpinnerDesc('Generating an open timestamp.') setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp( const timestamp = await generateTimestamp(extractNostrId(createSignature))
extractNostrId(createSignature)
)
const meta: Meta = { const meta: Meta = {
createSignature, createSignature,
@ -924,22 +927,35 @@ export const CreatePage = () => {
}) })
const isFirstSigner = signers[0].pubkey === usersPubkey const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) { if (isFirstSigner) {
navigate(appPrivateRoutes.sign, { state: { meta } }) navigate(appPrivateRoutes.sign, { state: { meta } })
} else { } else {
const createSignatureJson = JSON.parse(createSignature) const createSignatureJson = JSON.parse(createSignature)
navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`) navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`)
} }
} else { } catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
} finally {
setIsLoading(false)
}
}
const handleCreateOffline = async () => {
try {
const result = await initCreation()
if (!result) return
const { encryptionKey, markConfig, fileHashes } = result
const zip = new JSZip() const zip = new JSZip()
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file) zip.file(`files/${file.name}`, file)
}) })
const markConfig = createMarks(fileHashes)
setLoadingSpinnerDesc('Generating create signature') setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature( const createSignature = await generateCreateSignature(
markConfig, markConfig,
@ -973,7 +989,26 @@ export const CreatePage = () => {
encryptionKey encryptionKey
) )
await handleOfflineFlow(encryptedArrayBuffer, 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 }
})
} }
} 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

@ -54,7 +54,10 @@ export const RelaysPage = () => {
const relayMap = useAppSelector((state) => state.relays?.map) const relayMap = useAppSelector((state) => state.relays?.map)
const relaysInfo = useAppSelector((state) => state.relays?.info) const relaysInfo = useAppSelector((state) => state.relays?.info)
const webSocketPrefix = 'wss://' const webSocketPrefix =
newRelayURI?.startsWith('wss://') || newRelayURI?.startsWith('ws://')
? ''
: 'wss://'
// fetch relay list from relays // fetch relay list from relays
useEffect(() => { useEffect(() => {
@ -197,7 +200,7 @@ export const RelaysPage = () => {
// Check if new relay URI is a valid string // Check if new relay URI is a valid string
if ( if (
relayURI && relayURI &&
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( !/^wss?:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
relayURI relayURI
) )
) { ) {
@ -260,7 +263,13 @@ export const RelaysPage = () => {
}} }}
className={styles.relayURItextfield} className={styles.relayURItextfield}
/> />
<Button variant="contained" onClick={() => handleAddNewRelay()}> <Button
sx={{
height: '56px'
}}
variant="contained"
onClick={() => handleAddNewRelay()}
>
Add Add
</Button> </Button>
</Box> </Box>

View File

@ -12,6 +12,7 @@
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
align-items: start;
} }
.sectionIcon { .sectionIcon {

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, 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()
@ -59,23 +46,10 @@ export const SignPage = () => {
const usersAppData = useAppSelector((state) => state.userAppData) const usersAppData = useAppSelector((state) => state.userAppData)
/** /**
* Received from `location.state` * In the online mode, Sigit ID can be obtained either from the router state
* * using location or from UsersAppData
* 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
* meta (metaInNavState) will be received in navigation from create & home page in online mode
*/
let metaInNavState = location?.state?.meta || undefined
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
decryptedArrayBuffer: undefined,
uploadedZip: undefined
}
/**
* If userAppData (redux) is available, and we have the route param (sigit id)
* which is actually a `createEventId`, we will fetch a `sigit`
* based on the provided route ID and set fetched `sigit` to the `metaInNavState`
*/ */
const metaInNavState = useMemo(() => {
if (usersAppData) { if (usersAppData) {
const sigitCreateId = params.id const sigitCreateId = params.id
@ -83,11 +57,23 @@ export const SignPage = () => {
const sigit = usersAppData.sigits[sigitCreateId] const sigit = usersAppData.sigits[sigitCreateId]
if (sigit) { if (sigit) {
metaInNavState = sigit return sigit
} }
} }
} }
return location?.state?.meta || undefined
}, [location, usersAppData, params.id])
/**
* Received from `location.state`
*
* arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode
*/
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
decryptedArrayBuffer: undefined
}
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -217,65 +203,7 @@ 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(() => {
// online mode - from create and home page views
if (metaInNavState) { if (metaInNavState) {
const processSigit = async () => { const processSigit = async () => {
setIsLoading(true) setIsLoading(true)
@ -310,26 +238,20 @@ export const SignPage = () => {
} }
processSigit() processSigit()
} else if (decryptedArrayBuffer) { }
handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() =>
setIsLoading(false) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (decryptedArrayBuffer || uploadedZip) {
handleDecryptedArrayBuffer(decryptedArrayBuffer || uploadedZip).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, metaInNavState, decrypt]) }, [decryptedArrayBuffer, uploadedZip])
const handleArrayBufferFromBlossom = async ( const handleArrayBufferFromBlossom = async (
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
@ -388,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 } = {}
@ -471,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
} }
@ -500,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,
@ -511,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)
@ -519,19 +441,62 @@ export const SignPage = () => {
updatedMeta.modifiedAt = unixNow() updatedMeta.modifiedAt = unixNow()
} }
if (await isOnline()) {
await handleOnlineFlow(updatedMeta, encryptionKey) await handleOnlineFlow(updatedMeta, encryptionKey)
} else {
setMeta(updatedMeta) const createSignature = JSON.parse(updatedMeta.createSignature)
setIsLoading(false) navigate(`${appPublicRoutes.verify}/${createSignature.id}`)
} }
if (metaInNavState) { const handleSignOffline = async () => {
const createSignature = JSON.parse(metaInNavState.createSignature) const result = await initializeSigning('offline')
navigate(`${appPublicRoutes.verify}/${createSignature.id}`) if (!result) {
} else { setIsLoading(false)
navigate(appPrivateRoutes.homePage) return
} }
const { updatedMeta } = result
const zip = new JSZip()
for (const [filename, value] of Object.entries(files)) {
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
@ -562,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!)
@ -630,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,
@ -710,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
@ -847,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[]
@ -105,7 +106,10 @@ const SlimPdfView = ({
const m = parsedSignatureEvents[ const m = parsedSignatureEvents[
e as `npub1${string}` e as `npub1${string}`
].parsedContent?.marks.filter( ].parsedContent?.marks.filter(
(m) => m.pdfFileHash == hash && m.location.page == i (m) =>
(m.pdfFileHash
? m.pdfFileHash == hash
: m.fileHash == hash) && m.location.page == i
) )
if (m) { if (m) {
marks.push(...m) marks.push(...m)
@ -185,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)
/** /**
@ -205,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 {
@ -480,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 } = {}
@ -547,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) => {
@ -564,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,
@ -584,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 (isLastSigner) {
if (submittedBy) { if (submittedBy) {
userSet.add(submittedBy) userSet.add(submittedBy)
} }
signers.forEach((signer) => { signers.forEach((signer) => {
userSet.add(npubToHex(signer)!) userSet.add(npubToHex(signer)!)
}) })
viewers.forEach((viewer) => { viewers.forEach((viewer) => {
userSet.add(npubToHex(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( const keysFileContent = await generateKeysFile(
Array.from(userSet), Array.from(userSet),
@ -634,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`)
@ -644,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()
@ -653,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)
@ -675,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(
@ -736,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

@ -8,13 +8,16 @@ export interface CurrentUserMark {
currentValue?: string currentValue?: string
} }
// Both PdfFileHash and FileHash currently exist.
// It enables backward compatibility for Sigits created before January 2025
export interface Mark { export interface Mark {
id: number id: number
npub: string npub: string
pdfFileHash: string
type: MarkType type: MarkType
location: MarkLocation location: MarkLocation
fileName: string fileName: string
pdfFileHash?: string
fileHash?: string
value?: string value?: string
} }

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