Merge pull request 'New release' (#275) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m34s

Reviewed-on: #275
This commit is contained in:
b 2024-12-11 16:49:23 +00:00
commit 5ed3d2f389
56 changed files with 3988 additions and 712 deletions

View File

@ -6,7 +6,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs'],
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs', "*.min.js"],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {

View File

@ -8,6 +8,7 @@
</head>
<body>
<div id="root"></div>
<script src="/opentimestamps.min.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1685
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -41,6 +41,7 @@
"idb": "8.0.0",
"jszip": "3.10.1",
"lodash": "4.17.21",
"material-ui-popup-state": "^5.3.1",
"mui-file-input": "4.0.4",
"nostr-login": "^1.6.6",
"nostr-tools": "2.7.0",
@ -57,6 +58,7 @@
"react-singleton-hook": "^4.0.1",
"react-toastify": "10.0.4",
"redux": "5.0.1",
"signature_pad": "^5.0.4",
"tseep": "1.2.1"
},
"devDependencies": {
@ -66,6 +68,7 @@
"@types/pdfjs-dist": "^2.10.378",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@types/svgo": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
@ -78,6 +81,7 @@
"ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2",
"vite": "^5.1.4",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-tsconfig-paths": "4.3.2"
},
"lint-staged": {

View File

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

2
public/opentimestamps.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,16 +1,15 @@
import { useEffect } from 'react'
import { useAppSelector } from './hooks/store'
import { useAppSelector } from './hooks'
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthController } from './controllers'
import { MainLayout } from './layouts/Main'
import { appPrivateRoutes, appPublicRoutes } from './routes'
import './App.scss'
import {
appPrivateRoutes,
appPublicRoutes,
privateRoutes,
publicRoutes,
recursiveRouteRenderer
} from './routes'
import './App.scss'
} from './routes/util'
const App = () => {
const authState = useAppSelector((state) => state.auth)
@ -29,9 +28,11 @@ const App = () => {
const handleRootRedirect = () => {
if (authState.loggedIn) return appPrivateRoutes.homePage
const callbackPathEncoded = btoa(
window.location.href.split(`${window.location.origin}/#`)[1]
)
return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}`
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View File

@ -121,7 +121,17 @@ export const AppBar = () => {
<Container>
<Toolbar className={styles.toolbar} disableGutters={true}>
<Box className={styles.logoWrapper}>
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
<img
src="/logo.svg"
alt="Logo"
onClick={() => {
if (['', '#/'].includes(window.location.hash)) {
location.reload()
} else {
navigate('/')
}
}}
/>
</Box>
<Box className={styles.rightSideBox}>

View File

@ -42,8 +42,7 @@ export const DisplaySigit = ({
<div className={styles.itemWrapper}>
{signedStatus === SigitStatus.Complete && (
<Link
to={appPublicRoutes.verify}
state={{ meta }}
to={`${appPublicRoutes.verify}/${sigitCreateId}`}
className={styles.insetLink}
></Link>
)}

View File

@ -9,7 +9,7 @@ import {
} from '@mui/material'
import styles from './style.module.scss'
import React, { useEffect, useState } from 'react'
import { ProfileMetadata, User, UserRole } from '../../types'
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
import { SigitFile } from '../../utils/file'
@ -22,11 +22,20 @@ import { AvatarIconButton } from '../UserAvatarIconButton'
import { UserAvatar } from '../UserAvatar'
import _ from 'lodash'
const MINIMUM_RECT_SIZE = {
width: 21,
height: 21
} as const
const DEFAULT_START_SIZE = {
width: 140,
height: 40
} as const
interface HideSignersForDrawnField {
[key: number]: boolean
}
interface Props {
users: User[]
metadata: { [key: string]: ProfileMetadata }
@ -41,6 +50,9 @@ export const DrawPDFFields = (props: Props) => {
const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
const [hideSignersForDrawnField, setHideSignersForDrawnField] =
useState<HideSignersForDrawnField>({})
/**
* Return first pubkey that is present in the signers list
* @param pubkeys
@ -84,6 +96,7 @@ export const DrawPDFFields = (props: Props) => {
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const refreshPdfFiles = () => {
@ -142,6 +155,18 @@ export const DrawPDFFields = (props: Props) => {
* @param event Pointer event
*/
const handlePointerUp = () => {
sigitFiles.forEach((s) => {
s.pages?.forEach((p) => {
// Remove drawn fields below the MINIMUM_RECT_SIZE threshhold
p.drawnFields = p.drawnFields.filter(
(f) =>
!(
f.width < MINIMUM_RECT_SIZE.width ||
f.height < MINIMUM_RECT_SIZE.height
)
)
})
})
setMouseState((prev) => {
return {
...prev,
@ -150,6 +175,7 @@ export const DrawPDFFields = (props: Props) => {
resizing: false
}
})
refreshPdfFiles()
}
/**
@ -217,6 +243,12 @@ export const DrawPDFFields = (props: Props) => {
y: drawingRectangleCoords.y
}
})
// make signers dropdown visible
setHideSignersForDrawnField((prev) => ({
...prev,
[drawnFieldIndex]: false
}))
}
/**
@ -338,6 +370,32 @@ export const DrawPDFFields = (props: Props) => {
event.stopPropagation()
}
/**
* Handles Escape button-down event and hides all signers dropdowns
* @param event SyntheticEvent event
*/
const handleEscapeButtonDown = (event: React.SyntheticEvent) => {
// get native event
const { nativeEvent } = event
//check if event is a keyboard event
if (nativeEvent instanceof KeyboardEvent) {
// check if event code is Escape
if (nativeEvent.code === KeyboardCode.Escape) {
// hide all signers dropdowns
const newHideSignersForDrawnField: HideSignersForDrawnField = {}
Object.keys(hideSignersForDrawnField).forEach((key) => {
// Object.keys always returns an array of strings,
// that is why unknown type is used below
newHideSignersForDrawnField[key as unknown as number] = true
})
setHideSignersForDrawnField(newHideSignersForDrawnField)
}
}
}
/**
* Gets the pointer coordinates relative to a element in the `event` param
* @param event PointerEvent
@ -361,6 +419,7 @@ export const DrawPDFFields = (props: Props) => {
rect
}
}
/**
* Renders the pdf pages and drawing elements
*/
@ -375,6 +434,8 @@ export const DrawPDFFields = (props: Props) => {
<div
key={pageIndex}
className={`image-wrapper ${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
tabIndex={-1}
onKeyDown={(event) => handleEscapeButtonDown(event)}
>
<img
onPointerMove={(event) => {
@ -492,62 +553,78 @@ export const DrawPDFFields = (props: Props) => {
fileIndex,
pageIndex,
drawnFieldIndex
) && (
<div
onPointerDown={handleUserSelectPointerDown}
className={styles.userSelect}
>
<FormControl fullWidth size="small">
<InputLabel id="counterparts">Counterpart</InputLabel>
<Select
value={getAvailableSigner(drawnField.counterpart)}
onChange={(event) => {
drawnField.counterpart = event.target.value
setLastSigner(event.target.value)
refreshPdfFiles()
}}
labelId="counterparts"
label="Counterparts"
sx={{
background: 'white'
}}
renderValue={(value) =>
renderCounterpartValue(value)
}
>
{signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey]
const displayValue = getProfileUsername(
npub,
metadata
)
) &&
(!hideSignersForDrawnField ||
!hideSignersForDrawnField[drawnFieldIndex]) && (
<div
onPointerDown={handleUserSelectPointerDown}
className={styles.userSelect}
>
<FormControl fullWidth size="small">
<InputLabel id="counterparts">
Counterpart
</InputLabel>
<Select
value={getAvailableSigner(drawnField.counterpart)}
onChange={(event) => {
drawnField.counterpart = event.target.value
setLastSigner(event.target.value)
refreshPdfFiles()
}}
labelId="counterparts"
label="Counterparts"
sx={{
background: 'white'
}}
renderValue={(value) =>
renderCounterpartValue(value)
}
>
{signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey]
const displayValue = getProfileUsername(
npub,
metadata
)
// make current signers dropdown visible
if (
hideSignersForDrawnField[drawnFieldIndex] ===
undefined ||
hideSignersForDrawnField[drawnFieldIndex] ===
true
) {
setHideSignersForDrawnField((prev) => ({
...prev,
[drawnFieldIndex]: false
}))
}
return (
<MenuItem key={index} value={npub}>
<ListItemIcon>
<AvatarIconButton
src={metadata?.picture}
hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`}
color="inherit"
sx={{
padding: 0,
'> img': {
width: '30px',
height: '30px'
}
}}
/>
</ListItemIcon>
<ListItemText>{displayValue}</ListItemText>
</MenuItem>
)
})}
</Select>
</FormControl>
</div>
)}
return (
<MenuItem key={index} value={npub}>
<ListItemIcon>
<AvatarIconButton
src={metadata?.picture}
hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`}
color="inherit"
sx={{
padding: 0,
'> img': {
width: '30px',
height: '30px'
}
}}
/>
</ListItemIcon>
<ListItemText>{displayValue}</ListItemText>
</MenuItem>
)
})}
</Select>
</FormControl>
</div>
)}
</div>
)
})}

View File

@ -13,6 +13,10 @@
}
}
.pdfImageWrapper:focus {
outline: none;
}
.placeholder {
position: absolute;
opacity: 0.5;
@ -34,10 +38,6 @@
visibility: hidden;
}
&.edited {
outline: 1px dotted #01aaad;
}
.resizeHandle {
position: absolute;
right: -5px;
@ -95,3 +95,15 @@
height: 21px;
}
}
.signingRectangle {
position: absolute;
outline: 1px solid #01aaad;
z-index: 40;
background-color: #01aaad4b;
cursor: pointer;
&.edited {
outline: 1px dotted #01aaad;
}
}

View File

@ -1,23 +1,25 @@
import { CurrentUserFile } from '../../types/file.ts'
import styles from './style.module.scss'
import { Button } from '@mui/material'
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 {
files: CurrentUserFile[]
currentFile: CurrentUserFile
setCurrentFile: (file: CurrentUserFile) => void
handleDownload: () => void
downloadLabel?: string
handleExport: () => void
handleEncryptedExport?: () => void
}
const FileList = ({
files,
currentFile,
setCurrentFile,
handleDownload,
downloadLabel
handleExport,
handleEncryptedExport
}: FileListProps) => {
const isActive = (file: CurrentUserFile) => file.id === currentFile.id
return (
@ -42,9 +44,35 @@ const FileList = ({
</li>
))}
</ul>
<Button variant="contained" fullWidth onClick={handleDownload}>
{downloadLabel || 'Download Files'}
</Button>
<PopupState variant="popover" popupId="download-popup-menu">
{(popupState) => (
<React.Fragment>
<Button variant="contained" {...bindTrigger(popupState)}>
Export files
</Button>
<Menu {...bindMenu(popupState)}>
<MenuItem
onClick={() => {
popupState.close
handleExport()
}}
>
Export Files
</MenuItem>
<MenuItem
onClick={() => {
popupState.close
typeof handleEncryptedExport === 'function' &&
handleEncryptedExport()
}}
>
Export Encrypted Files
</MenuItem>
</Menu>
</React.Fragment>
)}
</PopupState>
</div>
)
}

View File

@ -68,6 +68,12 @@ export const Footer = () =>
}}
component={Link}
to={'/'}
onClick={(event) => {
if (['', '#/'].includes(window.location.hash)) {
event.preventDefault()
window.scrollTo(0, 0)
}
}}
variant={'text'}
>
Home

View File

@ -7,14 +7,15 @@ import {
isCurrentValueLast
} from '../../utils'
import React, { useState } from 'react'
import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (
event: React.ChangeEvent<HTMLInputElement>
) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
handleSelectedMarkValueChange: (value: string) => void
handleSubmit: (event: React.MouseEvent<HTMLButtonElement>) => void
selectedMark: CurrentUserMark
selectedMarkValue: string
}
@ -31,11 +32,13 @@ const MarkFormField = ({
handleCurrentUserMarkChange
}: MarkFormFieldProps) => {
const [displayActions, setDisplayActions] = useState(true)
const [complete, setComplete] = useState(false)
const isReadyToSign = () =>
isCurrentUserMarksComplete(currentUserMarks) ||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
const isCurrent = (currentMark: CurrentUserMark) =>
currentMark.id === selectedMark.id
currentMark.id === selectedMark.id && !complete
const isDone = (currentMark: CurrentUserMark) =>
isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted
const findNext = () => {
@ -47,12 +50,36 @@ const MarkFormField = ({
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
console.log('handle form submit runs...')
return isReadyToSign()
? handleSubmit(event)
: handleCurrentUserMarkChange(findNext()!)
// Without this line, we lose mark values when switching
handleCurrentUserMarkChange(selectedMark)
if (!complete) {
isReadyToSign()
? setComplete(true)
: handleCurrentUserMarkChange(findNext()!)
}
}
const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type)
const handleCurrentUserMarkClick = (mark: CurrentUserMark) => {
setComplete(false)
handleCurrentUserMarkChange(mark)
}
const handleSelectCompleteMark = () => {
handleCurrentUserMarkChange(selectedMark)
setComplete(true)
}
const handleSignAndComplete = (
event: React.MouseEvent<HTMLButtonElement>
) => {
handleSubmit(event)
}
return (
<div className={styles.container}>
<div className={styles.trigger}>
@ -78,31 +105,51 @@ const MarkFormField = ({
<div className={styles.actionsWrapper}>
<div className={styles.actionsTop}>
<div className={styles.actionsTopInfo}>
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
{!complete && (
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
)}
{complete && <p className={styles.actionsTopInfoText}>Finish</p>}
</div>
</div>
<div className={styles.inputWrapper}>
<form onSubmit={(e) => handleFormSubmit(e)}>
<input
className={styles.input}
placeholder={markLabel}
onChange={handleSelectedMarkValueChange}
value={selectedMarkValue}
/>
{!complete && (
<form onSubmit={(e) => handleFormSubmit(e)}>
<MarkInput
markType={selectedMark.mark.type}
key={selectedMark.id}
value={selectedMarkValue}
placeholder={markLabel}
handler={handleSelectedMarkValueChange}
userMark={selectedMark}
/>
<div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}>
NEXT
</button>
</div>
</form>
)}
{complete && (
<div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}>
NEXT
<button
onClick={handleSignAndComplete}
className={styles.submitButton}
disabled={!isReadyToSign()}
>
SIGN AND COMPLETE
</button>
</div>
</form>
)}
<div className={styles.footerContainer}>
<div className={styles.footer}>
{currentUserMarks.map((mark, index) => {
return (
<div className={styles.pagination} key={index}>
<button
className={`${styles.paginationButton} ${isDone(mark) && styles.paginationButtonDone}`}
onClick={() => handleCurrentUserMarkChange(mark)}
className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
onClick={() => handleCurrentUserMarkClick(mark)}
>
{mark.id}
</button>
@ -112,6 +159,20 @@ const MarkFormField = ({
</div>
)
})}
<div className={styles.pagination}>
<button
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
onClick={handleSelectCompleteMark}
>
<FontAwesomeIcon
className={styles.finishPage}
icon={faCheck}
/>
</button>
{complete && (
<div className={styles.paginationButtonCurrent}></div>
)}
</div>
</div>
</div>
</div>

View File

@ -122,7 +122,7 @@
align-items: center;
grid-gap: 15px;
box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1);
max-width: 750px;
max-width: 450px;
&.expanded {
display: flex;
@ -216,3 +216,7 @@
flex-direction: column;
grid-gap: 5px;
}
.finishPage {
padding: 1px 0;
}

View File

@ -0,0 +1,16 @@
import { MarkType } from '../../types/drawing'
import { MARK_TYPE_CONFIG, MarkInputProps } from './MarkStrategy'
interface MarkInputComponentProps extends MarkInputProps {
markType: MarkType
}
export const MarkInput = ({ markType, ...rest }: MarkInputComponentProps) => {
const { input: InputComponent } = MARK_TYPE_CONFIG[markType] || {}
if (typeof InputComponent !== 'undefined') {
return <InputComponent {...rest} />
}
return null
}

View File

@ -0,0 +1,20 @@
import { MarkType } from '../../types/drawing'
import { MARK_TYPE_CONFIG, MarkRenderProps } from './MarkStrategy'
interface MarkRenderComponentProps extends MarkRenderProps {
markType: MarkType
}
export const MarkRender = ({ markType, ...rest }: MarkRenderComponentProps) => {
const { render: RenderComponent } = MARK_TYPE_CONFIG[markType] || {}
if (typeof RenderComponent !== 'undefined') {
return <RenderComponent {...rest} />
}
return <DefaultRenderComponent {...rest} />
}
const DefaultRenderComponent = ({ value }: MarkRenderProps) => (
<span>{value}</span>
)

View File

@ -0,0 +1,32 @@
import { MarkType } from '../../types/drawing'
import { CurrentUserMark, Mark } from '../../types/mark'
import { TextStrategy } from './Text'
import { SignatureStrategy } from './Signature'
export interface MarkInputProps {
value: string
handler: (value: string) => void
placeholder?: string
userMark?: CurrentUserMark
}
export interface MarkRenderProps {
value?: string
mark: Mark
}
export interface MarkStrategy {
input: React.FC<MarkInputProps>
render: React.FC<MarkRenderProps>
encryptAndUpload?: (value: string, key?: string) => Promise<string>
fetchAndDecrypt?: (value: string, key?: string) => Promise<string>
}
export type MarkStrategies = {
[key in MarkType]?: MarkStrategy
}
export const MARK_TYPE_CONFIG: MarkStrategies = {
[MarkType.TEXT]: TextStrategy,
[MarkType.SIGNATURE]: SignatureStrategy
}

View File

@ -0,0 +1,44 @@
@import '../../../styles/colors.scss';
$padding: 5px;
.wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: $padding;
}
.relative {
position: relative;
outline: 1px solid black;
}
.canvas {
background-color: $body-background-color;
cursor: crosshair;
// Disable panning/zooming when touching canvas element
-ms-touch-action: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.absolute {
position: absolute;
inset: 0;
pointer-events: none;
}
.reset {
cursor: pointer;
position: absolute;
top: 0;
right: $padding;
color: $primary-main;
&:hover {
color: $primary-dark;
}
}

View File

@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef } from 'react'
import { faEraser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { MarkRenderSignature } from './Render'
import SignaturePad from 'signature_pad'
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils/const'
import { BasicPoint } from 'signature_pad/dist/types/point'
import { MarkInputProps } from '../MarkStrategy'
import styles from './Input.module.scss'
export const MarkInputSignature = ({
value,
handler,
userMark
}: MarkInputProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const signaturePad = useRef<SignaturePad | null>(null)
const update = useCallback(() => {
const data = signaturePad.current?.toData()
const reduced = data?.map((pg) => pg.points)
const json = JSON.stringify(reduced)
if (signaturePad.current && !signaturePad.current?.isEmpty()) {
handler(json)
} else {
handler('')
}
}, [handler])
useEffect(() => {
const handleEndStroke = () => {
update()
}
if (canvasRef.current) {
if (signaturePad.current === null) {
signaturePad.current = new SignaturePad(
canvasRef.current,
SIGNATURE_PAD_OPTIONS
)
}
signaturePad.current.addEventListener('endStroke', handleEndStroke)
}
return () => {
window.removeEventListener('endStroke', handleEndStroke)
}
}, [update])
useEffect(() => {
if (signaturePad.current) {
if (value) {
signaturePad.current.fromData(
JSON.parse(value).map((p: BasicPoint[]) => ({
points: p
}))
)
} else {
signaturePad.current?.clear()
}
}
update()
}, [update, value])
const handleReset = () => {
signaturePad.current?.clear()
update()
}
return (
<div className={styles.wrapper}>
<div
className={styles.relative}
style={{
width: SIGNATURE_PAD_SIZE.width,
height: SIGNATURE_PAD_SIZE.height
}}
>
<canvas
width={SIGNATURE_PAD_SIZE.width}
height={SIGNATURE_PAD_SIZE.height}
ref={canvasRef}
className={styles.canvas}
></canvas>
{typeof userMark?.mark !== 'undefined' && (
<div className={styles.absolute}>
<MarkRenderSignature
key={userMark.mark.value}
value={userMark.mark.value}
mark={userMark.mark}
/>
</div>
)}
<div className={styles.reset}>
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,9 @@
.img {
width: 100%;
height: 100%;
object-fit: contain;
overflow: hidden;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import SignaturePad from 'signature_pad'
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils'
import { BasicPoint } from 'signature_pad/dist/types/point'
import { MarkRenderProps } from '../MarkStrategy'
import styles from './Render.module.scss'
export const MarkRenderSignature = ({ value }: MarkRenderProps) => {
const [dataUrl, setDataUrl] = useState<string | undefined>()
useEffect(() => {
if (value) {
const canvas = document.createElement('canvas')
canvas.width = SIGNATURE_PAD_SIZE.width
canvas.height = SIGNATURE_PAD_SIZE.height
const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS)
pad.fromData(
JSON.parse(value).map((p: BasicPoint[]) => ({
points: p
}))
)
setDataUrl(canvas.toDataURL('image/webp'))
}
}, [value])
return dataUrl ? <img src={dataUrl} className={styles.img} alt="" /> : null
}

View File

@ -0,0 +1,95 @@
import axios from 'axios'
import {
decryptArrayBuffer,
encryptArrayBuffer,
getHash,
isOnline,
uploadToFileStorage
} from '../../../utils'
import { MarkStrategy } from '../MarkStrategy'
import { MarkInputSignature } from './Input'
import { MarkRenderSignature } from './Render'
export const SignatureStrategy: MarkStrategy = {
input: MarkInputSignature,
render: MarkRenderSignature,
encryptAndUpload: async (value, encryptionKey) => {
// Value is the stringified signature object
// Encode it to the arrayBuffer
const encoder = new TextEncoder()
const uint8Array = encoder.encode(value)
if (!encryptionKey) {
throw new Error('Signature requires an encryption key')
}
// Encrypt the file contents with the same encryption key from the create signature
const encryptedArrayBuffer = await encryptArrayBuffer(
uint8Array,
encryptionKey
)
const hash = await getHash(encryptedArrayBuffer)
if (!hash) {
throw new Error("Can't get encrypted file hash.")
}
// Create the encrypted json file from array buffer and hash
const file = new File([encryptedArrayBuffer], `${hash}.json`)
if (await isOnline()) {
try {
const url = await uploadToFileStorage(file)
console.info(`${file.name} uploaded to file storage`)
return url
} catch (error) {
if (error instanceof Error) {
console.error(
`Error occurred in uploading file ${file.name}`,
error.message
)
}
}
} else {
// TOOD: offline
}
return value
},
fetchAndDecrypt: async (value, encryptionKey) => {
if (!encryptionKey) {
throw new Error('Signature requires an encryption key')
}
const encryptedArrayBuffer = await axios.get(value, {
responseType: 'arraybuffer'
})
// Verify hash
const parts = value.split('/')
const urlHash = parts[parts.length - 1]
const hash = await getHash(encryptedArrayBuffer.data)
if (hash !== urlHash) {
// TODO: handle hash verification failing
throw new Error('Unable to verify signature')
}
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer.data,
encryptionKey
).catch((err) => {
console.log('err in decryption:>> ', err)
return null
})
if (arrayBuffer) {
// decode json
const decoder = new TextDecoder()
const json = decoder.decode(arrayBuffer)
return json
}
// TOOD: offline
return value
}
}

View File

@ -0,0 +1,19 @@
import { MarkInputProps } from '../MarkStrategy'
import styles from '../../MarkFormField/style.module.scss'
export const MarkInputText = ({
value,
handler,
placeholder
}: MarkInputProps) => {
return (
<input
className={styles.input}
placeholder={placeholder}
onChange={(e) => {
handler(e.currentTarget.value)
}}
value={value}
/>
)
}

View File

@ -0,0 +1,7 @@
import { MarkStrategy } from '../MarkStrategy'
import { MarkInputText } from './Input'
export const TextStrategy: MarkStrategy = {
input: MarkInputText,
render: ({ value }) => <>{value}</>
}

View File

@ -4,6 +4,7 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx'
import { forwardRef } from 'react'
import { npubToHex } from '../../utils/nostr.ts'
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
interface PdfMarkItemProps {
userMark: CurrentUserMark
@ -31,7 +32,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
<div
ref={ref}
onClick={handleClick}
className={`file-mark ${styles.drawingRectangle} ${isEdited() && styles.edited}`}
className={`file-mark ${styles.signingRectangle} ${isEdited() && styles.edited}`}
style={{
backgroundColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
@ -47,7 +48,12 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
fontSize: inPx(from(pageWidth, FONT_SIZE))
}}
>
{getMarkValue()}
<MarkRender
key={getMarkValue()}
markType={userMark.mark.type}
value={getMarkValue()}
mark={userMark.mark}
/>
</div>
)
}

View File

@ -24,11 +24,12 @@ import {
interface PdfMarkingProps {
currentUserMarks: CurrentUserMark[]
files: CurrentUserFile[]
handleDownload: () => void
handleExport: () => void
handleEncryptedExport: () => void
handleSign: () => void
meta: Meta | null
otherUserMarks: Mark[]
setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void
setIsMarksCompleted: (isMarksCompleted: boolean) => void
setUpdatedMarks: (markToUpdate: Mark) => void
}
@ -42,10 +43,11 @@ const PdfMarking = (props: PdfMarkingProps) => {
const {
files,
currentUserMarks,
setIsMarksCompleted,
setCurrentUserMarks,
setUpdatedMarks,
handleDownload,
handleExport,
handleEncryptedExport,
handleSign,
meta,
otherUserMarks
} = props
@ -70,8 +72,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
const handleMarkClick = (id: number) => {
const nextMark = currentUserMarks.find((mark) => mark.mark.id === id)
setSelectedMark(nextMark!)
setSelectedMarkValue(nextMark?.mark.value ?? EMPTY)
if (nextMark) handleCurrentUserMarkChange(nextMark)
}
const handleCurrentUserMarkChange = (mark: CurrentUserMark) => {
@ -86,11 +88,18 @@ const PdfMarking = (props: PdfMarkingProps) => {
updatedSelectedMark
)
setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMarkValue(mark.currentValue ?? EMPTY)
setSelectedMark(mark)
// If clicking on the same mark, don't update the value, otherwise do update
if (mark.id !== selectedMark.id) {
setSelectedMarkValue(mark.currentValue ?? EMPTY)
setSelectedMark(mark)
}
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
/**
* Sign and Complete
*/
const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
if (!selectedMarkValue || !selectedMark) return
@ -106,8 +115,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
)
setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMark(null)
setIsMarksCompleted(true)
setUpdatedMarks(updatedMark.mark)
handleSign()
}
// const updateCurrentUserMarkValues = () => {
@ -117,8 +126,9 @@ const PdfMarking = (props: PdfMarkingProps) => {
// setCurrentUserMarks(updatedCurrentUserMarks)
// }
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setSelectedMarkValue(event.target.value)
const handleChange = (value: string) => {
setSelectedMarkValue(value)
}
return (
<>
@ -131,7 +141,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
files={files}
currentFile={currentFile}
setCurrentFile={setCurrentFile}
handleDownload={handleDownload}
handleExport={handleExport}
handleEncryptedExport={handleEncryptedExport}
/>
)}
</div>

View File

@ -6,6 +6,7 @@ import { useEffect, useRef } from 'react'
import pdfViewStyles from './style.module.scss'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx'
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
interface PdfPageProps {
fileName: string
pageIndex: number
@ -73,7 +74,7 @@ const PdfPageItem = ({
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{m.value}
<MarkRender value={m.value} mark={m} markType={m.type} />
</div>
)
})}

View File

@ -4,6 +4,7 @@ import {
fromUnixTimestamp,
hexToNpub,
npubToHex,
SigitStatus,
SignStatus
} from '../../utils'
import { useSigitMeta } from '../../hooks/useSigitMeta'
@ -15,6 +16,8 @@ import {
faCalendar,
faCalendarCheck,
faCalendarPlus,
faCheck,
faClock,
faEye,
faFile,
faFileCircleExclamation
@ -22,7 +25,7 @@ import {
import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useAppSelector } from '../../hooks/store'
import { DisplaySigner } from '../DisplaySigner'
import { Meta } from '../../types'
import { Meta, OpenTimestamp } from '../../types'
import { extractFileExtensions } from '../../utils/file'
import { UserAvatar } from '../UserAvatar'
@ -42,7 +45,9 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
completedAt,
parsedSignatureEvents,
signedStatus,
isValid
isValid,
id,
timestamps
} = useSigitMeta(meta)
const { usersPubkey } = useAppSelector((state) => state.auth)
const userCanSign =
@ -51,6 +56,50 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
const isTimestampVerified = (
timestamps: OpenTimestamp[],
nostrId: string
): boolean => {
const matched = timestamps.find((t) => t.nostrId === nostrId)
return !!(matched && matched.verification)
}
const getOpenTimestampsInfo = (
timestamps: OpenTimestamp[],
nostrId: string
) => {
if (isTimestampVerified(timestamps, nostrId)) {
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
} else {
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
}
}
const getCompletedOpenTimestampsInfo = (timestamp: OpenTimestamp) => {
if (timestamp.verification) {
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
} else {
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
}
}
const getTimestampTooltipTitle = (label: string, isVerified: boolean) => {
return `${label} / Open Timestamp ${isVerified ? 'Verified' : 'Pending'}`
}
const isUserSignatureTimestampVerified = () => {
if (
userCanSign &&
hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0
) {
const nostrId = parsedSignatureEvents[hexToNpub(usersPubkey)].id
return isTimestampVerified(timestamps, nostrId)
}
return false
}
return submittedBy ? (
<div className={styles.container}>
<div className={styles.section}>
@ -115,19 +164,35 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<p>Details</p>
<Tooltip
title={'Publication date'}
title={getTimestampTooltipTitle(
'Publication date',
!!(timestamps && id && isTimestampVerified(timestamps, id))
)}
placement="top"
arrow
disableInteractive
>
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarPlus} />{' '}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}{' '}
{timestamps &&
timestamps.length > 0 &&
id &&
getOpenTimestampsInfo(timestamps, id)}
</span>
</Tooltip>
<Tooltip
title={'Completion date'}
title={getTimestampTooltipTitle(
'Completion date',
!!(
signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 &&
timestamps[timestamps.length - 1].verification
)
)}
placement="top"
arrow
disableInteractive
@ -135,13 +200,26 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarCheck} />{' '}
{completedAt ? formatTimestamp(completedAt) : <>&mdash;</>}
{signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getCompletedOpenTimestampsInfo(
timestamps[timestamps.length - 1]
)}
</span>
)}
</span>
</Tooltip>
{/* User signed date */}
{userCanSign ? (
<Tooltip
title={'Your signature date'}
title={getTimestampTooltipTitle(
'Your signature date',
isUserSignatureTimestampVerified()
)}
placement="top"
arrow
disableInteractive
@ -161,6 +239,16 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
) : (
<>&mdash;</>
)}
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getOpenTimestampsInfo(
timestamps,
parsedSignatureEvents[hexToNpub(usersPubkey)].id
)}
</span>
)}
</span>
</Tooltip>
) : null}

View File

@ -31,8 +31,6 @@
padding: 5px;
display: flex;
align-items: center;
justify-content: start;
> :first-child {
padding: 5px;
@ -44,3 +42,7 @@
color: white;
}
}
.ticket {
margin-left: auto;
}

View File

@ -14,7 +14,6 @@ import {
compareObjects,
getAuthToken,
getRelayMap,
getVisitedLink,
saveAuthToken,
unixNow
} from '../utils'
@ -91,21 +90,33 @@ export class AuthController {
store.dispatch(setRelayMapAction(relayMap.map))
}
const currentLocation = window.location.hash.replace('#', '')
/**
* This block was added before we started using the `nostr-login` package
* At this point it seems it's not needed anymore and it's even blocking the flow (reloading on /verify)
* TODO to remove this if app works fine
*/
// const currentLocation = window.location.hash.replace('#', '')
if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
// User did change the location to one of the private routes
const visitedLink = getVisitedLink()
if (visitedLink) {
const { pathname, search } = visitedLink
return Promise.resolve(`${pathname}${search}`)
} else {
// Navigate user in
return Promise.resolve(appPrivateRoutes.homePage)
}
}
// if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
// // Since verify is both public and private route, we don't use the `visitedLink`
// // value for it. Otherwise, when linking to /verify/:id we get redirected
// // to the root `/`
// if (currentLocation.includes(appPublicRoutes.verify)) {
// return Promise.resolve(currentLocation)
// }
//
// // User did change the location to one of the private routes
// const visitedLink = getVisitedLink()
//
// if (visitedLink) {
// const { pathname, search } = visitedLink
//
// return Promise.resolve(`${pathname}${search}`)
// } else {
// // Navigate user in
// return Promise.resolve(appPrivateRoutes.homePage)
// }
// }
}
checkSession() {

View File

@ -3,7 +3,8 @@ import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent
SignedEventContent,
OpenTimestamp
} from '../types'
import { Mark } from '../types/mark'
import {
@ -20,6 +21,7 @@ import { Event } from 'nostr-tools'
import store from '../store/store'
import { NostrController } from '../controllers'
import { MetaParseError } from '../types/errors/MetaParseError'
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy'
/**
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
@ -58,6 +60,8 @@ export interface FlatMeta
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
timestamps?: OpenTimestamp[]
}
/**
@ -139,6 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig)
setZipUrl(zipUrl)
let encryptionKey: string | null = null
if (meta.keys) {
const { sender, keys } = meta.keys
// Retrieve the user's public key from the state
@ -159,10 +164,10 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
return null
})
encryptionKey = decrypted
setEncryptionKey(decrypted)
}
}
// Temp. map to hold events and signers
const parsedSignatureEventsMap = new Map<
`npub1${string}`,
@ -204,13 +209,40 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
}
}
parsedSignatureEventsMap.forEach((event, npub) => {
for (const [npub, event] of parsedSignatureEventsMap) {
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
// get the signature of prev signer from the content of current signers signedEvent
const prevSignersSig = getPrevSignerSig(npub)
try {
const obj: SignedEventContent = JSON.parse(event.content)
// Signature object can include values that need to be fetched and decrypted
for (let i = 0; i < obj.marks.length; i++) {
const m = obj.marks[i]
try {
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
if (
typeof fetchAndDecrypt === 'function' &&
m.value &&
encryptionKey
) {
const decrypted = await fetchAndDecrypt(
m.value,
encryptionKey
)
obj.marks[i].value = decrypted
}
} catch (error) {
console.error(
`Error during mark fetchAndDecrypt phase`,
error
)
}
}
parsedSignatureEventsMap.set(npub, {
...event,
parsedContent: obj
@ -226,7 +258,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid)
}
}
})
}
signers
.filter((s) => !parsedSignatureEventsMap.has(s))
@ -276,6 +308,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
createSignature: meta?.createSignature,
docSignatures: meta?.docSignatures,
keys: meta?.keys,
timestamps: meta?.timestamps,
isValid,
kind,
tags,

View File

@ -1,10 +1,17 @@
import styles from './style.module.scss'
import { Button, FormHelperText, TextField, Tooltip } from '@mui/material'
import {
Box,
Button,
CircularProgress,
FormHelperText,
TextField,
Tooltip
} from '@mui/material'
import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd'
import { MultiBackend } from 'react-dnd-multi-backend'
import { HTML5toTouch } from 'rdndmb-html5-to-touch'
@ -13,12 +20,18 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController, NostrController } from '../../controllers'
import {
MetadataController,
NostrController,
RelayController
} from '../../controllers'
import { appPrivateRoutes } from '../../routes'
import {
CreateSignatureEventContent,
KeyboardCode,
Meta,
ProfileMetadata,
SignedEvent,
User,
UserRole
} from '../../types'
@ -56,12 +69,19 @@ import {
faGripLines,
faPen,
faPlus,
faSearch,
faToolbox,
faTrash,
faUpload
} from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts'
import _ from 'lodash'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { Autocomplete } from '@mui/lab'
import _, { truncate } from 'lodash'
import * as React from 'react'
import { AvatarIconButton } from '../../components/UserAvatarIconButton'
type FoundUser = Event & { npub: string }
export const CreatePage = () => {
const navigate = useNavigate()
@ -84,20 +104,16 @@ export const CreatePage = () => {
}
const [userInput, setUserInput] = useState('')
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
handleAddUser()
}
}
const [userRole, setUserRole] = useState<UserRole>(UserRole.signer)
const [userSearchInput, setUserSearchInput] = useState('')
const [userRole] = useState<UserRole>(UserRole.signer)
const [error, setError] = useState<string>()
const [users, setUsers] = useState<User[]>([])
const signers = users.filter((u) => u.role === UserRole.signer)
const viewers = users.filter((u) => u.role === UserRole.viewer)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)!
const nostrController = NostrController.getInstance()
@ -106,10 +122,161 @@ export const CreatePage = () => {
)
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false)
const searchFieldRef = useRef<HTMLInputElement>(null)
const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [foundUsers, setFoundUsers] = useState<FoundUser[]>([])
const [searchUsersLoading, setSearchUsersLoading] = useState<boolean>(false)
const [pastedUserNpubOrNip05, setPastedUserNpubOrNip05] = useState<
string | undefined
>()
/**
* Fired when user select
*/
const handleSearchUserChange = useCallback(
(_event: React.SyntheticEvent, value: string | FoundUser | null) => {
if (typeof value === 'object') {
const ndkEvent = value as FoundUser
if (ndkEvent?.pubkey) {
setUserInput(hexToNpub(ndkEvent.pubkey))
}
}
},
[setUserInput]
)
const handleSearchUserNip05 = async (
nip05: string
): Promise<string | null> => {
const { pubkey } = await queryNip05(nip05).catch((err) => {
console.error(err)
return { pubkey: null }
})
return pubkey
}
const handleSearchUsers = async (searchValue?: string) => {
const searchString = searchValue || userSearchInput || undefined
if (!searchString) return
setSearchUsersLoading(true)
const relayController = RelayController.getInstance()
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController.findRelayListMetadata(usersPubkey)
const searchTerm = searchString.trim()
relayController
.fetchEvents(
{
kinds: [0],
search: searchTerm
},
[...relaySet.write]
)
.then((events) => {
console.log('events', events)
const fineFilteredEvents: FoundUser[] = events
.filter((event) => {
const lowercaseContent = event.content.toLowerCase()
return (
lowercaseContent.includes(
`"name":"${searchTerm.toLowerCase()}"`
) ||
lowercaseContent.includes(
`"display_name":"${searchTerm.toLowerCase()}"`
) ||
lowercaseContent.includes(
`"username":"${searchTerm.toLowerCase()}"`
) ||
lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`)
)
})
.reduce((uniqueEvents: FoundUser[], event: Event) => {
if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) {
uniqueEvents.push({
...event,
npub: hexToNpub(event.pubkey)
})
}
return uniqueEvents
}, [])
console.info('fineFilteredEvents', fineFilteredEvents)
setFoundUsers(fineFilteredEvents)
if (!fineFilteredEvents.length)
toast.info('No user found with the provided search term')
})
.catch((error) => {
console.error(error)
})
.finally(() => {
setSearchUsersLoading(false)
})
}
useEffect(() => {
setTimeout(() => {
if (foundUsers.length) {
if (searchFieldRef.current) {
searchFieldRef.current.blur()
searchFieldRef.current.focus()
}
}
})
}, [foundUsers])
const handleInputKeyDown = async (
event: React.KeyboardEvent<HTMLDivElement>
) => {
if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
event.preventDefault()
// If pasted user npub of nip05 is present, we just add the user to the counterparts list
if (pastedUserNpubOrNip05) {
setUserInput(pastedUserNpubOrNip05)
setPastedUserNpubOrNip05(undefined)
} else {
// Otherwize if search already provided some results, user must manually click the search button
if (!foundUsers.length) {
// If it's NIP05 (includes @ or is a valid domain) send request to .well-known
const domainRegex = /^[a-zA-Z0-9@.-]+\.[a-zA-Z]{2,}$/
if (domainRegex.test(userSearchInput)) {
setSearchUsersLoading(true)
const pubkey = await handleSearchUserNip05(userSearchInput)
setSearchUsersLoading(false)
if (pubkey) {
setUserInput(userSearchInput)
} else {
toast.error(`No user found with the NIP05: ${userSearchInput}`)
}
} else {
handleSearchUsers()
}
}
}
}
}
useEffect(() => {
if (selectedFiles) {
/**
* Reads the binary files and converts to internal file type
* Reads the binary files and converts to an internal file type
* and sets to a state (adds images if it's a PDF)
*/
const parsePages = async () => {
@ -129,8 +296,6 @@ export const CreatePage = () => {
}
}, [selectedFiles])
const [selectedTool, setSelectedTool] = useState<DrawTool>()
/**
* Changes the drawing tool
* @param drawTool to draw with
@ -203,7 +368,7 @@ export const CreatePage = () => {
}
}, [usersPubkey])
const handleAddUser = async () => {
const handleAddUser = useCallback(async () => {
setError(undefined)
const addUser = (pubkey: string) => {
@ -245,6 +410,8 @@ export const CreatePage = () => {
const input = userInput.toLowerCase()
setUserSearchInput('')
if (input.startsWith('npub')) {
return handleAddNpubUser(input)
}
@ -294,7 +461,20 @@ export const CreatePage = () => {
}
return
}
}
}, [
userInput,
userRole,
setError,
setUsers,
setUserSearchInput,
setIsLoading,
setLoadingSpinnerDesc,
setUserInput
])
useEffect(() => {
if (userInput?.length > 0) handleAddUser()
}, [handleAddUser, userInput])
const handleUserRoleChange = (role: UserRole, pubkey: string) => {
setUsers((prevUsers) =>
@ -613,7 +793,7 @@ export const CreatePage = () => {
title
}
setLoadingSpinnerDesc('Signing nostr event for create signature')
setLoadingSpinnerDesc('Preparing document(s) for signing')
const createSignature = await signEventForMetaFile(
JSON.stringify(content),
@ -642,6 +822,11 @@ export const CreatePage = () => {
return receivers.map((receiver) => sendNotification(receiver, meta))
}
const extractNostrId = (stringifiedEvent: string): string => {
const e = JSON.parse(stringifiedEvent) as SignedEvent
return e.id
}
const handleCreate = async () => {
try {
if (!validateInputs()) return
@ -691,6 +876,12 @@ export const CreatePage = () => {
const keys = await generateKeys(pubkeys, encryptionKey)
if (!keys) return
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(
extractNostrId(createSignature)
)
const meta: Meta = {
createSignature,
keys,
@ -698,6 +889,10 @@ export const CreatePage = () => {
docSignatures: {}
}
if (timestamp) {
meta.timestamps = [timestamp]
}
setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta)
if (!event) return
@ -768,6 +963,48 @@ export const CreatePage = () => {
}
}
/**
* Handles the user search textfield change
* If it's not valid npub or nip05, search will be automatically triggered
*/
const handleSearchAutocompleteTextfieldChange = async (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const value = e.target.value
const disarmAddOnEnter = () => {
setPastedUserNpubOrNip05(undefined)
}
// Seems like it's npub format
if (value.startsWith('npub')) {
// We will try to convert npub to hex and if it's successfull that means
// npub is valid
const validHexPubkey = npubToHex(value)
if (validHexPubkey) {
// Arm the manual user npub add after enter is hit, we don't want to trigger search
setPastedUserNpubOrNip05(value)
} else {
disarmAddOnEnter()
}
} else {
// Disarm the add user on enter hit, and trigger search after 1 second
disarmAddOnEnter()
}
setUserSearchInput(value)
}
const parseContent = (event: Event) => {
try {
return JSON.parse(event.content)
} catch (e) {
return undefined
console.error(e)
}
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
@ -831,42 +1068,110 @@ export const CreatePage = () => {
moveSigner={moveSigner}
/>
</div>
<div className={styles.addCounterpart}>
<div className={styles.inputWrapper}>
<TextField
fullWidth
placeholder="Add counterpart"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleInputKeyDown}
error={!!error}
<Autocomplete
sx={{ width: 300 }}
options={foundUsers}
onChange={handleSearchUserChange}
inputValue={userSearchInput}
disableClearable
openOnFocus
autoHighlight
freeSolo
filterOptions={(x) => x}
getOptionLabel={(option) => {
let label: string = (option as FoundUser).npub
const contentJson = parseContent(option as FoundUser)
if (contentJson?.name) {
label = contentJson.name
} else {
label = option as string
}
return label
}}
renderOption={(props, option) => {
const { ...optionProps } = props
const contentJson = parseContent(option)
return (
<Box
component="li"
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...optionProps}
key={option.pubkey}
>
<AvatarIconButton
src={contentJson.picture}
hexKey={option.pubkey}
color="inherit"
sx={{
padding: '0 10px 0 0'
}}
/>
<div>
{contentJson.name}{' '}
{usersPubkey === option.pubkey ? (
<span
style={{
color: '#4c82a3',
fontWeight: 'bold'
}}
>
Me
</span>
) : (
''
)}{' '}
({truncate(option.npub, { length: 16 })})
</div>
</Box>
)
}}
renderInput={(params) => (
<TextField
{...params}
key={params.id}
inputRef={searchFieldRef}
label="Add/Search counterpart"
onKeyDown={handleInputKeyDown}
onChange={handleSearchAutocompleteTextfieldChange}
/>
)}
/>
</div>
<Button
onClick={() =>
setUserRole(
userRole === UserRole.signer
? UserRole.viewer
: UserRole.signer
)
}
variant="contained"
aria-label="Toggle User Role"
className={styles.counterpartToggleButton}
>
<FontAwesomeIcon
icon={userRole === UserRole.signer ? faPen : faEye}
/>
</Button>
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
aria-label="Add"
className={styles.counterpartToggleButton}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
{!pastedUserNpubOrNip05 ? (
<Button
disabled={!userSearchInput || searchUsersLoading}
onClick={() => handleSearchUsers()}
variant="contained"
aria-label="Add"
className={styles.counterpartToggleButton}
>
{searchUsersLoading ? (
<CircularProgress size={14} />
) : (
<FontAwesomeIcon icon={faSearch} />
)}
</Button>
) : (
<Button
onClick={() => {
setUserInput(userSearchInput)
}}
variant="contained"
aria-label="Add"
className={styles.counterpartToggleButton}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
)}
</div>
<div className={`${styles.paperGroup} ${styles.toolbox}`}>

View File

@ -135,6 +135,46 @@ export const HomePage = () => {
const [filter, setFilter] = useState<Filter>('Show all')
const [sort, setSort] = useState<Sort>('desc')
const renderSubmissions = () => {
const submissions = Object.keys(parsedSigits)
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
if (submissions.length) {
return submissions.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))
} else {
return (
<div className={styles.noResults}>
<p>No results</p>
</div>
)
}
}
return (
<div {...getRootProps()} tabIndex={-1}>
<Container className={styles.container}>
@ -233,36 +273,8 @@ export const HomePage = () => {
<label htmlFor="file-upload">Click or drag files to upload!</label>
)}
</button>
<div className={styles.submissions}>
{Object.keys(parsedSigits)
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))}
</div>
<div className={styles.submissions}>{renderSubmissions()}</div>
</Container>
<Footer />
</div>

View File

@ -99,3 +99,10 @@
gap: 25px;
grid-template-columns: repeat(auto-fit, minmax(365px, 1fr));
}
.noResults {
display: flex;
justify-content: center;
font-weight: normal;
color: #a1a1a1;
}

View File

@ -9,6 +9,7 @@ import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { AuthController } from '../../controllers'
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
import { KeyboardCode } from '../../types'
import { LoginMethod } from '../../store/auth/types'
import { hexToBytes } from '@noble/hashes/utils'
@ -52,7 +53,10 @@ export const Nostr = () => {
* Call login function when enter is pressed
*/
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
event.preventDefault()
login()
}

View File

@ -1,64 +1,54 @@
import { Box, Button, Typography } from '@mui/material'
import axios from 'axios'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
import { useAppSelector } from '../../hooks/store'
import { useAppSelector } from '../../hooks'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import { appPublicRoutes } from '../../routes'
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
import {
ARRAY_BUFFER,
decryptArrayBuffer,
DEFLATE,
encryptArrayBuffer,
extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey,
filterMarksByPubkey,
findOtherUserMarks,
generateEncryptionKey,
generateKeysFile,
getCurrentUserFiles,
getCurrentUserMarks,
getHash,
hexToNpub,
isOnline,
loadZip,
unixNow,
npubToHex,
parseJson,
processMarks,
readContentOfZipEntry,
sendNotification,
signEventForMetaFile,
updateUsersAppData,
findOtherUserMarks,
timeout
timeout,
unixNow,
updateMarks,
updateUsersAppData
} from '../../utils'
import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta'
import styles from './style.module.scss'
import { CurrentUserMark, Mark } from '../../types/mark.ts'
import { getLastSignersSig, isFullySigned } from '../../utils/sign.ts'
import {
filterMarksByPubkey,
getCurrentUserMarks,
isCurrentUserMarksComplete,
updateMarks
} from '../../utils'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
import {
convertToSigitFile,
getZipWithFiles,
SigitFile
} from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
User_Is_Not_Next_Signer
}
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
import { getLastSignersSig } from '../../utils/sign.ts'
export const SignPage = () => {
const navigate = useNavigate()
@ -97,17 +87,12 @@ export const SignPage = () => {
}
}
const [displayInput, setDisplayInput] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
const [submittedBy, setSubmittedBy] = useState<string>()
@ -121,66 +106,14 @@ export const SignPage = () => {
[key: string]: string | null
}>({})
const [signedBy, setSignedBy] = useState<`npub1${string}`[]>([])
const [nextSinger, setNextSinger] = useState<string>()
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
[]
)
const [isMarksCompleted, setIsMarksCompleted] = useState(false)
const [otherUserMarks, setOtherUserMarks] = useState<Mark[]>([])
useEffect(() => {
if (signers.length > 0) {
// check if all signers have signed then its fully signed
if (isFullySigned(signers, signedBy)) {
setSignedStatus(SignedStatus.Fully_Signed)
} else {
for (const signer of signers) {
if (!signedBy.includes(signer)) {
// signers in meta.json are in npub1 format
// so, convert it to hex before setting to nextSigner
setNextSinger(npubToHex(signer)!)
const usersNpub = hexToNpub(usersPubkey!)
if (signer === usersNpub) {
// logged in user is the next signer
setSignedStatus(SignedStatus.User_Is_Next_Signer)
} else {
setSignedStatus(SignedStatus.User_Is_Not_Next_Signer)
}
break
}
}
}
} else {
// there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed)
}
// Determine and set the status of the user
if (submittedBy && usersPubkey && submittedBy === usersPubkey) {
// If the submission was made by the user, set the status to true
setIsSignerOrCreator(true)
} else if (usersPubkey) {
// Convert the user's public key from hex to npub format
const usersNpub = hexToNpub(usersPubkey)
if (signers.includes(usersNpub)) {
// If the user's npub is in the list of signers, set the status to true
setIsSignerOrCreator(true)
}
}
}, [signers, signedBy, usersPubkey, submittedBy])
useEffect(() => {
const handleUpdatedMeta = async (meta: Meta) => {
const createSignatureEvent = await parseJson<Event>(
@ -236,42 +169,53 @@ export const SignPage = () => {
const signedMarks = extractMarksFromSignedMeta(meta)
const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks)
const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!)
if (meta.keys) {
for (let i = 0; i < otherUserMarks.length; i++) {
const m = otherUserMarks[i]
const { sender, keys } = meta.keys
const usersNpub = hexToNpub(usersPubkey)
if (usersNpub in keys) {
const encryptionKey = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
console.log(
'An error occurred in decrypting encryption key',
err
)
return null
})
try {
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
if (
typeof fetchAndDecrypt === 'function' &&
m.value &&
encryptionKey
) {
otherUserMarks[i].value = await fetchAndDecrypt(
m.value,
encryptionKey
)
}
} catch (error) {
console.error(`Error during mark fetchAndDecrypt phase`, error)
}
}
}
}
setOtherUserMarks(otherUserMarks)
setCurrentUserMarks(currentUserMarks)
setIsMarksCompleted(isCurrentUserMarksComplete(currentUserMarks))
}
setSignedBy(Object.keys(meta.docSignatures) as `npub1${string}`[])
}
if (meta) {
handleUpdatedMeta(meta)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta, usersPubkey])
const handleDownload = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
setLoadingSpinnerDesc('Generating file')
try {
const zip = await getZipWithFiles(meta, files)
const arrayBuffer = await zip.generateAsync({
type: ARRAY_BUFFER,
compression: DEFLATE,
compressionOptions: {
level: 6
}
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
} catch (error) {
console.log('error in zip:>> ', error)
if (error instanceof Error) {
toast.error(error.message || 'Error occurred in generating zip file')
}
}
}
const decrypt = useCallback(
async (file: File) => {
setLoadingSpinnerDesc('Decrypting file')
@ -383,7 +327,6 @@ export const SignPage = () => {
})
} else {
setIsLoading(false)
setDisplayInput(true)
}
}, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt])
@ -500,9 +443,6 @@ export const SignPage = () => {
setFiles(files)
setCurrentFileHashes(fileHashes)
setDisplayInput(false)
setLoadingSpinnerDesc('Parsing meta.json')
const metaFileContent = await readContentOfZipEntry(
@ -530,29 +470,14 @@ export const SignPage = () => {
setMeta(parsedMetaJson)
}
const handleDecrypt = async () => {
if (!selectedFile) return
setIsLoading(true)
const arrayBuffer = await decrypt(selectedFile)
if (!arrayBuffer) {
setIsLoading(false)
toast.error('Error decrypting file')
return
}
handleDecryptedArrayBuffer(arrayBuffer)
}
const handleSign = async () => {
if (Object.entries(files).length === 0 || !meta) return
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!))
const usersNpub = hexToNpub(usersPubkey!)
const prevSig = getPrevSignersSig(usersNpub)
if (!prevSig) {
setIsLoading(false)
toast.error('Previous signature is invalid')
@ -561,17 +486,51 @@ export const SignPage = () => {
const marks = getSignerMarksForMeta() || []
const signedEvent = await signEventForMeta({ prevSig, marks })
let encryptionKey: string | undefined
if (meta.keys) {
const { sender, keys } = meta.keys
encryptionKey = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
// Log and display an error message if decryption fails
console.log('An error occurred in decrypting encryption key', err)
toast.error('An error occurred in decrypting encryption key')
return undefined
})
}
const processedMarks = await processMarks(marks, encryptionKey)
const signedEvent = await signEventForMeta({
prevSig,
marks: processedMarks
})
if (!signedEvent) return
const updatedMeta = updateMetaSignatures(meta, signedEvent)
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(signedEvent.id)
if (timestamp) {
updatedMeta.timestamps = [...(updatedMeta.timestamps || []), timestamp]
updatedMeta.modifiedAt = unixNow()
}
if (await isOnline()) {
await handleOnlineFlow(updatedMeta)
} else {
setMeta(updatedMeta)
setIsLoading(false)
}
if (metaInNavState) {
const createSignature = JSON.parse(metaInNavState.createSignature)
navigate(`${appPublicRoutes.verify}/${createSignature.id}`)
} else {
navigate(appPrivateRoutes.homePage)
}
}
// Sign the event for the meta file
@ -662,6 +621,14 @@ export const SignPage = () => {
})
}
// 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
}
// Handle errors during zip file generation
const handleZipError = (err: unknown) => {
console.log('Error in zip:>> ', err)
@ -672,7 +639,7 @@ export const SignPage = () => {
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 (meta: Meta) => {
setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta)
@ -727,16 +694,38 @@ export const SignPage = () => {
setIsLoading(false)
}
// 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
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 handleExport = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
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 (
@ -744,15 +733,15 @@ export const SignPage = () => {
!viewers.includes(usersNpub) &&
submittedBy !== usersNpub
)
return
return Promise.resolve(null)
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return
if (!meta) return Promise.resolve(null)
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return
if (!prevSig) return Promise.resolve(null)
const signedEvent = await signEventForMetaFile(
JSON.stringify({
@ -762,7 +751,7 @@ export const SignPage = () => {
setIsLoading
)
if (!signedEvent) return
if (!signedEvent) return Promise.resolve(null)
const exportSignature = JSON.stringify(signedEvent, null, 2)
@ -780,8 +769,8 @@ export const SignPage = () => {
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
type: ARRAY_BUFFER,
compression: DEFLATE,
compressionOptions: {
level: 6
}
@ -793,50 +782,9 @@ export const SignPage = () => {
return null
})
if (!arrayBuffer) return
if (!arrayBuffer) return Promise.resolve(null)
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
navigate(appPublicRoutes.verify)
}
const handleEncryptedExport = async () => {
if (Object.entries(files).length === 0 || !meta) return
const stringifiedMeta = JSON.stringify(meta, null, 2)
const zip = await getZipWithFiles(meta, files)
zip.file('meta.json', stringifiedMeta)
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arrayBuffer) return
const 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`)
return Promise.resolve(arrayBuffer)
}
/**
@ -876,90 +824,17 @@ export const SignPage = () => {
return <LoadingSpinner desc={loadingSpinnerDesc} />
}
if (!isMarksCompleted && signedStatus === SignedStatus.User_Is_Next_Signer) {
return (
<PdfMarking
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks}
setIsMarksCompleted={setIsMarksCompleted}
setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks}
handleDownload={handleDownload}
otherUserMarks={otherUserMarks}
meta={meta}
/>
)
}
return (
<>
<Container className={styles.container}>
{displayInput && (
<>
<Typography component="label" variant="h6">
Select sigit file
</Typography>
<Box className={styles.inputBlock}>
<MuiFileInput
placeholder="Select file"
inputProps={{ accept: '.sigit.zip' }}
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
/>
</Box>
{selectedFile && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleDecrypt} variant="contained">
Decrypt
</Button>
</Box>
)}
</>
)}
{submittedBy && Object.entries(files).length > 0 && meta && (
<>
<DisplayMeta
meta={meta}
files={files}
submittedBy={submittedBy}
signers={signers}
viewers={viewers}
creatorFileHashes={creatorFileHashes}
currentFileHashes={currentFileHashes}
signedBy={signedBy}
nextSigner={nextSinger}
getPrevSignersSig={getPrevSignersSig}
/>
{signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
)}
{signedStatus === SignedStatus.User_Is_Next_Signer && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSign} variant="contained">
Sign
</Button>
</Box>
)}
{isSignerOrCreator && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleEncryptedExport} variant="contained">
Export Encrypted Sigit
</Button>
</Box>
)}
</>
)}
</Container>
</>
<PdfMarking
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks}
setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks}
handleSign={handleSign}
handleExport={handleExport}
handleEncryptedExport={handleEncryptedExport}
otherUserMarks={otherUserMarks}
meta={meta}
/>
)
}

View File

@ -5,7 +5,13 @@ import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import { DocSignatureEvent, Meta } from '../../types'
import {
DocSignatureEvent,
Meta,
SignedEvent,
OpenTimestamp,
OpenTimestampUpgradeVerifyResponse
} from '../../types'
import {
decryptArrayBuffer,
getHash,
@ -14,19 +20,27 @@ import {
parseJson,
readContentOfZipEntry,
signEventForMetaFile,
getCurrentUserFiles
getCurrentUserFiles,
updateUsersAppData,
npubToHex,
sendNotification,
generateEncryptionKey,
encryptArrayBuffer,
generateKeysFile,
ARRAY_BUFFER,
DEFLATE
} from '../../utils'
import styles from './style.module.scss'
import { useLocation } from 'react-router-dom'
import { useLocation, useParams } from 'react-router-dom'
import axios from 'axios'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useAppSelector } from '../../hooks/store'
import { useAppSelector } from '../../hooks'
import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver'
import { Container } from '../../components/Container'
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx'
import { UsersDetails } from '../../components/UsersDetails.tsx'
import FileList from '../../components/FileList'
import { CurrentUserFile } from '../../types/file.ts'
import { Mark } from '../../types/mark.ts'
@ -44,6 +58,9 @@ import {
faFile,
faFileDownload
} from '@fortawesome/free-solid-svg-icons'
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
import _ from 'lodash'
import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx'
interface PdfViewProps {
files: CurrentUserFile[]
@ -118,7 +135,11 @@ const SlimPdfView = ({
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{m.value}
<MarkRender
markType={m.type}
value={m.value}
mark={m}
/>
</div>
)
})}
@ -149,6 +170,9 @@ const SlimPdfView = ({
export const VerifyPage = () => {
const location = useLocation()
const params = useParams()
const usersAppData = useAppSelector((state) => state.userAppData)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
@ -160,8 +184,27 @@ export const VerifyPage = () => {
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json
* meta will be received in navigation from create & home page in online mode
*/
const { uploadedZip, meta: metaInNavState } = location.state || {}
let metaInNavState = location?.state?.meta || undefined
const { uploadedZip } = location.state || {}
const [selectedFile, setSelectedFile] = useState<File | null>(null)
/**
* If `userAppData` is present it means user is logged in and we can extract list of `sigits` from the store.
* If ID is present in the URL we search in the `sigits` list
* Otherwise sigit is set from the `location.state.meta`
*/
if (usersAppData) {
const sigitCreateId = params.id
if (sigitCreateId) {
const sigit = usersAppData.sigits[sigitCreateId]
if (sigit) {
metaInNavState = sigit
}
}
}
useEffect(() => {
if (uploadedZip) {
setSelectedFile(uploadedZip)
@ -169,6 +212,7 @@ export const VerifyPage = () => {
}, [uploadedZip])
const [meta, setMeta] = useState<Meta>(metaInNavState)
const {
submittedBy,
zipUrl,
@ -176,7 +220,8 @@ export const VerifyPage = () => {
signers,
viewers,
fileHashes,
parsedSignatureEvents
parsedSignatureEvents,
timestamps
} = useSigitMeta(meta)
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
@ -186,6 +231,16 @@ export const VerifyPage = () => {
[key: string]: string | null
}>({})
const signTimestampEvent = async (signerContent: {
timestamps: OpenTimestamp[]
}): Promise<SignedEvent | null> => {
return await signEventForMetaFile(
JSON.stringify(signerContent),
nostrController,
setIsLoading
)
}
useEffect(() => {
if (Object.entries(files).length > 0) {
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
@ -193,88 +248,224 @@ export const VerifyPage = () => {
}
}, [currentFileHashes, fileHashes, files])
useEffect(() => {
if (
timestamps &&
timestamps.length > 0 &&
usersPubkey &&
submittedBy &&
parsedSignatureEvents
) {
if (timestamps.every((t) => !!t.verification)) {
return
}
const upgradeT = async (timestamps: OpenTimestamp[]) => {
try {
setLoadingSpinnerDesc('Upgrading your timestamps.')
const findCreatorTimestamp = (timestamps: OpenTimestamp[]) => {
if (usersPubkey === submittedBy) {
return timestamps[0]
}
}
const findSignerTimestamp = (timestamps: OpenTimestamp[]) => {
const parsedEvent = parsedSignatureEvents[hexToNpub(usersPubkey)]
if (parsedEvent?.id) {
return timestamps.find((t) => t.nostrId === parsedEvent.id)
}
}
/**
* Checks if timestamp verification has been achieved for the first time.
* Note that the upgrade flag is separate from verification. It is possible for a timestamp
* to not be upgraded, but to be verified for the first time.
* @param upgradedTimestamp
* @param timestamps
*/
const isNewlyVerified = (
upgradedTimestamp: OpenTimestampUpgradeVerifyResponse,
timestamps: OpenTimestamp[]
) => {
if (!upgradedTimestamp.verified) return false
const oldT = timestamps.find(
(t) => t.nostrId === upgradedTimestamp.timestamp.nostrId
)
if (!oldT) return false
if (!oldT.verification && upgradedTimestamp.verified) return true
}
const userTimestamps: OpenTimestamp[] = []
const creatorTimestamp = findCreatorTimestamp(timestamps)
if (creatorTimestamp) {
userTimestamps.push(creatorTimestamp)
}
const signerTimestamp = findSignerTimestamp(timestamps)
if (signerTimestamp) {
userTimestamps.push(signerTimestamp)
}
if (userTimestamps.every((t) => !!t.verification)) {
return
}
const upgradedUserTimestamps = await Promise.all(
userTimestamps.map(upgradeAndVerifyTimestamp)
)
const upgradedTimestamps = upgradedUserTimestamps
.filter((t) => t.upgraded || isNewlyVerified(t, userTimestamps))
.map((t) => {
const timestamp: OpenTimestamp = { ...t.timestamp }
if (t.verified) {
timestamp.verification = t.verification
}
return timestamp
})
if (upgradedTimestamps.length === 0) {
return
}
setLoadingSpinnerDesc('Signing a timestamp upgrade event.')
const signedEvent = await signTimestampEvent({
timestamps: upgradedTimestamps
})
if (!signedEvent) return
const finalTimestamps = timestamps.map((t) => {
const upgraded = upgradedTimestamps.find(
(tu) => tu.nostrId === t.nostrId
)
if (upgraded) {
return {
...upgraded,
signature: JSON.stringify(signedEvent, null, 2)
}
}
return t
})
const updatedMeta = _.cloneDeep(meta)
updatedMeta.timestamps = [...finalTimestamps]
updatedMeta.modifiedAt = unixNow()
const updatedEvent = await updateUsersAppData(updatedMeta)
if (!updatedEvent) return
const userSet = new Set<`npub1${string}`>()
signers.forEach((signer) => {
if (signer !== usersPubkey) {
userSet.add(signer)
}
})
viewers.forEach((viewer) => {
userSet.add(viewer)
})
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, updatedMeta)
)
await Promise.all(promises)
toast.success('Timestamp updates have been sent successfully.')
setMeta(meta)
} catch (err) {
console.error(err)
toast.error(
'There was an error upgrading or verifying your timestamps!'
)
}
}
upgradeT(timestamps)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timestamps, submittedBy, parsedSignatureEvents])
useEffect(() => {
if (metaInNavState && encryptionKey) {
const processSigit = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Fetching file from file server')
axios
.get(zipUrl, {
try {
const res = await axios.get(zipUrl, {
responseType: 'arraybuffer'
})
.then(async (res) => {
const fileName = zipUrl.split('/').pop()
const file = new File([res.data], fileName!)
const encryptedArrayBuffer = await file.arrayBuffer()
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
).catch((err) => {
console.log('err in decryption:>> ', err)
const fileName = zipUrl.split('/').pop()
const file = new File([res.data], fileName!)
const encryptedArrayBuffer = await file.arrayBuffer()
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
).catch((err) => {
console.log('err in decryption:>> ', err)
toast.error(err.message || 'An error occurred in decrypting file.')
return null
})
if (arrayBuffer) {
const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => {
console.log('err in loading zip file :>> ', err)
toast.error(
err.message || 'An error occurred in decrypting file.'
err.message || 'An error occurred in loading zip file.'
)
return null
})
if (arrayBuffer) {
const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => {
console.log('err in loading zip file :>> ', err)
toast.error(
err.message || 'An error occurred in loading zip file.'
)
return null
})
if (!zip) return
if (!zip) return
const files: { [fileName: string]: SigitFile } = {}
const fileHashes: { [key: string]: string | null } = {}
const fileNames = Object.values(zip.files).map(
(entry) => entry.name
)
const files: { [fileName: string]: SigitFile } = {}
const fileHashes: { [key: string]: string | null } = {}
const fileNames = Object.values(zip.files).map(
(entry) => entry.name
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
for (const fileName of fileNames) {
const arrayBuffer = await readContentOfZipEntry(
zip,
fileName,
'arraybuffer'
)
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
for (const fileName of fileNames) {
const arrayBuffer = await readContentOfZipEntry(
zip,
fileName,
'arraybuffer'
if (arrayBuffer) {
files[fileName] = await convertToSigitFile(
arrayBuffer,
fileName!
)
const hash = await getHash(arrayBuffer)
if (arrayBuffer) {
files[fileName] = await convertToSigitFile(
arrayBuffer,
fileName!
)
const hash = await getHash(arrayBuffer)
if (hash) {
fileHashes[fileName.replace(/^files\//, '')] = hash
}
} else {
fileHashes[fileName.replace(/^files\//, '')] = null
if (hash) {
fileHashes[fileName.replace(/^files\//, '')] = hash
}
} else {
fileHashes[fileName.replace(/^files\//, '')] = null
}
setCurrentFileHashes(fileHashes)
setFiles(files)
setIsLoading(false)
}
})
.catch((err) => {
console.error(`error occurred in getting file from ${zipUrl}`, err)
toast.error(
err.message || `error occurred in getting file from ${zipUrl}`
)
})
.finally(() => {
setCurrentFileHashes(fileHashes)
setFiles(files)
setIsLoading(false)
})
}
} catch (err) {
const message = `error occurred in getting file from ${zipUrl}`
console.error(message, err)
if (err instanceof Error) toast.error(err.message)
else toast.error(message)
} finally {
setIsLoading(false)
}
}
processSigit()
@ -355,8 +546,114 @@ export const VerifyPage = () => {
setIsLoading(false)
}
const handleMarkedExport = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
// 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
}
// 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
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'
})
}
const handleExport = async () => {
const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
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 (
@ -364,14 +661,14 @@ export const VerifyPage = () => {
!viewers.includes(usersNpub) &&
submittedBy !== usersNpub
) {
return
return Promise.resolve(null)
}
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return
if (!prevSig) return Promise.resolve(null)
const signedEvent = await signEventForMetaFile(
JSON.stringify({ prevSig }),
@ -379,7 +676,7 @@ export const VerifyPage = () => {
setIsLoading
)
if (!signedEvent) return
if (!signedEvent) return Promise.resolve(null)
const exportSignature = JSON.stringify(signedEvent, null, 2)
const updatedMeta = { ...meta, exportSignature }
@ -390,8 +687,8 @@ export const VerifyPage = () => {
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
type: ARRAY_BUFFER,
compression: DEFLATE,
compressionOptions: {
level: 6
}
@ -403,12 +700,9 @@ export const VerifyPage = () => {
return null
})
if (!arrayBuffer) return
if (!arrayBuffer) return Promise.resolve(null)
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
return Promise.resolve(arrayBuffer)
}
return (
@ -454,8 +748,8 @@ export const VerifyPage = () => {
)}
currentFile={currentFile}
setCurrentFile={setCurrentFile}
handleDownload={handleMarkedExport}
downloadLabel="Download Sigit"
handleExport={handleExport}
handleEncryptedExport={handleEncryptedExport}
/>
)
}

View File

@ -53,10 +53,6 @@
.mark {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
[data-dev='true'] {

View File

@ -1,16 +1,4 @@
import { CreatePage } from '../pages/create'
import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing'
import { ProfilePage } from '../pages/profile'
import { SettingsPage } from '../pages/settings/Settings'
import { CacheSettingsPage } from '../pages/settings/cache'
import { NostrLoginPage } from '../pages/settings/nostrLogin'
import { ProfileSettingsPage } from '../pages/settings/profile'
import { RelaysPage } from '../pages/settings/relays'
import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify'
import { hexToNpub } from '../utils'
import { Route, RouteProps } from 'react-router-dom'
export const appPrivateRoutes = {
homePage: '/',
@ -39,93 +27,3 @@ export const getProfileRoute = (hexKey: string) =>
export const getProfileSettingsRoute = (hexKey: string) =>
appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey))
/**
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
*/
type CustomRouteProps<T> = T &
Omit<RouteProps, 'children'> & {
children?: Array<CustomRouteProps<T>>
}
/**
* This function maps over nested routes with optional condition for rendering
* @param {CustomRouteProps<T>[]} routes - routes list
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
*/
export function recursiveRouteRenderer<T>(
routes?: CustomRouteProps<T>[],
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
true
) {
if (!routes) return null
// Callback allows us to pass arbitrary conditions for each route's rendering
// Skipping the callback will by default evaluate to true (show route)
return routes.map((route, index) =>
renderConditionCallbackFn(route) ? (
<Route
key={`${route.path}${index}`}
path={route.path}
element={route.element}
>
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
</Route>
) : null
)
}
type PublicRouteProps = CustomRouteProps<{
hiddenWhenLoggedIn?: boolean
}>
export const publicRoutes: PublicRouteProps[] = [
{
path: appPublicRoutes.landingPage,
hiddenWhenLoggedIn: true,
element: <LandingPage />
},
{
path: appPublicRoutes.profile,
element: <ProfilePage />
},
{
path: appPublicRoutes.verify,
element: <VerifyPage />
}
]
export const privateRoutes = [
{
path: appPrivateRoutes.homePage,
element: <HomePage />
},
{
path: appPrivateRoutes.create,
element: <CreatePage />
},
{
path: `${appPrivateRoutes.sign}/:id?`,
element: <SignPage />
},
{
path: appPrivateRoutes.settings,
element: <SettingsPage />
},
{
path: appPrivateRoutes.profileSettings,
element: <ProfileSettingsPage />
},
{
path: appPrivateRoutes.cacheSettings,
element: <CacheSettingsPage />
},
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
},
{
path: appPrivateRoutes.nostrLogin,
element: <NostrLoginPage />
}
]

103
src/routes/util.tsx Normal file
View File

@ -0,0 +1,103 @@
import { Route, RouteProps } from 'react-router-dom'
import { appPrivateRoutes, appPublicRoutes } from '.'
import { CreatePage } from '../pages/create'
import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing'
import { ProfilePage } from '../pages/profile'
import { CacheSettingsPage } from '../pages/settings/cache'
import { NostrLoginPage } from '../pages/settings/nostrLogin'
import { ProfileSettingsPage } from '../pages/settings/profile'
import { RelaysPage } from '../pages/settings/relays'
import { SettingsPage } from '../pages/settings/Settings'
import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify'
/**
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
*/
type CustomRouteProps<T> = T &
Omit<RouteProps, 'children'> & {
children?: Array<CustomRouteProps<T>>
}
/**
* This function maps over nested routes with optional condition for rendering
* @param {CustomRouteProps<T>[]} routes - routes list
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
*/
export function recursiveRouteRenderer<T>(
routes?: CustomRouteProps<T>[],
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
true
) {
if (!routes) return null
// Callback allows us to pass arbitrary conditions for each route's rendering
// Skipping the callback will by default evaluate to true (show route)
return routes.map((route, index) =>
renderConditionCallbackFn(route) ? (
<Route
key={`${route.path}${index}`}
path={route.path}
element={route.element}
>
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
</Route>
) : null
)
}
type PublicRouteProps = CustomRouteProps<{
hiddenWhenLoggedIn?: boolean
}>
export const publicRoutes: PublicRouteProps[] = [
{
path: appPublicRoutes.landingPage,
hiddenWhenLoggedIn: true,
element: <LandingPage />
},
{
path: appPublicRoutes.profile,
element: <ProfilePage />
},
{
path: `${appPublicRoutes.verify}/:id?`,
element: <VerifyPage />
}
]
export const privateRoutes = [
{
path: appPrivateRoutes.homePage,
element: <HomePage />
},
{
path: appPrivateRoutes.create,
element: <CreatePage />
},
{
path: `${appPrivateRoutes.sign}/:id?`,
element: <SignPage />
},
{
path: appPrivateRoutes.settings,
element: <SettingsPage />
},
{
path: appPrivateRoutes.profileSettings,
element: <ProfileSettingsPage />
},
{
path: appPrivateRoutes.cacheSettings,
element: <CacheSettingsPage />
},
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
},
{
path: appPrivateRoutes.nostrLogin,
element: <NostrLoginPage />
}
]

View File

@ -18,6 +18,7 @@ export interface Meta {
docSignatures: { [key: `npub1${string}`]: string }
exportSignature?: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
timestamps?: OpenTimestamp[]
}
export interface CreateSignatureEventContent {
@ -39,6 +40,25 @@ export interface Sigit {
meta: Meta
}
export interface OpenTimestamp {
nostrId: string
value: string
verification?: OpenTimestampVerification
signature?: string
}
export interface OpenTimestampVerification {
height: number
timestamp: number
}
export interface OpenTimestampUpgradeVerifyResponse {
timestamp: OpenTimestamp
upgraded: boolean
verified?: boolean
verification?: OpenTimestampVerification
}
export interface UserAppData {
/**
* Key will be id of create signature

5
src/types/event.ts Normal file
View File

@ -0,0 +1,5 @@
export enum KeyboardCode {
Escape = 'Escape',
Enter = 'Enter',
NumpadEnter = 'NumpadEnter'
}

View File

@ -4,3 +4,4 @@ export * from './nostr'
export * from './profile'
export * from './relay'
export * from './zip'
export * from './event'

38
src/types/opentimestamps.d.ts vendored Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface OpenTimestamps {
// Create a detached timestamp file from a buffer or file hash
DetachedTimestampFile: {
fromHash(op: any, hash: Uint8Array): any
fromBytes(op: any, buffer: Uint8Array): any
deserialize(buffer: any): any
}
// Stamp the provided timestamp file and return a Promise
stamp(file: any): Promise<void>
// Verify the provided timestamp proof file
verify(
ots: string,
file: string
): Promise<TimestampVerficiationResponse | Record<string, never>>
// Other utilities or operations (like OpSHA256, serialization)
Ops: {
OpSHA256: any
OpSHA1?: any
}
Context: {
StreamSerialization: any
}
// Load a timestamp file from a buffer
deserialize(bytes: Uint8Array): any
// Other potential methods based on repo functions
upgrade(file: any): Promise<boolean>
}
interface TimestampVerficiationResponse {
bitcoin: { timestamp: number; height: number }
}

View File

@ -3,5 +3,6 @@ import type { WindowNostr } from 'nostr-tools/nip07'
declare global {
interface Window {
nostr?: WindowNostr
OpenTimestamps: OpenTimestamps
}
}

View File

@ -112,3 +112,13 @@ export const MOST_COMMON_MEDIA_TYPES = new Map([
['3g2', 'video/3gpp2'], // 3GPP2 audio/video container
['7z', 'application/x-7z-compressed'] // 7-zip archive
])
export const SIGNATURE_PAD_OPTIONS = {
minWidth: 0.5,
maxWidth: 3
} as const
export const SIGNATURE_PAD_SIZE = {
width: 300,
height: 150
}

View File

@ -1,7 +1,11 @@
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy.tsx'
import { NostrController } from '../controllers/NostrController.ts'
import store from '../store/store.ts'
import { Meta } from '../types'
import { PdfPage } from '../types/drawing.ts'
import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
import { extractMarksFromSignedMeta } from './mark.ts'
import { hexToNpub } from './nostr.ts'
import {
addMarks,
groupMarksByFileNamePage,
@ -21,7 +25,49 @@ export const getZipWithFiles = async (
for (const [fileName, file] of Object.entries(files)) {
// Handle PDF Files, add marks
if (file.isPdf && fileName in marksByFileNamePage) {
const blob = await addMarks(file, marksByFileNamePage[fileName])
const marksToAdd = marksByFileNamePage[fileName]
if (meta.keys) {
for (let i = 0; i < marks.length; i++) {
const m = marks[i]
const { sender, keys } = meta.keys
const usersPubkey = store.getState().auth.usersPubkey!
const usersNpub = hexToNpub(usersPubkey)
if (usersNpub in keys) {
const encryptionKey = await NostrController.getInstance()
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
console.log(
'An error occurred in decrypting encryption key',
err
)
return null
})
try {
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
if (
typeof fetchAndDecrypt === 'function' &&
m.value &&
encryptionKey
) {
// Fetch and decrypt the original file
const link = m.value.split('/')
const decrypted = await fetchAndDecrypt(m.value, encryptionKey)
// Save decrypted
zip.file(
`signatures/${link[link.length - 1]}.json`,
new Blob([decrypted])
)
marks[i].value = decrypted
}
} catch (error) {
console.error(`Error during mark fetchAndDecrypt phase`, error)
}
}
}
}
const blob = await addMarks(file, marksToAdd)
zip.file(`marked/${fileName}`, blob)
}

View File

@ -11,3 +11,4 @@ export * from './string'
export * from './url'
export * from './utils'
export * from './zip'
export * from './const'

View File

@ -24,6 +24,7 @@ import {
faStamp,
faTableCellsLarge
} from '@fortawesome/free-solid-svg-icons'
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy.tsx'
/**
* Takes in an array of Marks already filtered by User.
@ -158,6 +159,11 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
icon: faT,
label: 'Text'
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature'
},
{
identifier: MarkType.FULLNAME,
icon: faIdCard,
@ -170,12 +176,6 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
label: 'Job Title',
isComingSoon: true
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature',
isComingSoon: true
},
{
identifier: MarkType.DATETIME,
icon: faClock,
@ -266,6 +266,40 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => {
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
}
export const getOptimizedPathsWithStrokeWidth = (svgString: string) => {
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(svgString, 'image/svg+xml')
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]
for (let i = 0; i < _marks.length; i++) {
const mark = _marks[i]
const hasProcess =
mark.type in MARK_TYPE_CONFIG &&
typeof MARK_TYPE_CONFIG[mark.type]?.encryptAndUpload === 'function'
if (hasProcess) {
const value = mark.value!
const processFn = MARK_TYPE_CONFIG[mark.type]?.encryptAndUpload
if (processFn) {
mark.value = await processFn(value, encryptionKey)
}
}
}
return _marks
}
export {
getCurrentUserMarks,
filterMarksByPubkey,

119
src/utils/opentimestamps.ts Normal file
View File

@ -0,0 +1,119 @@
import { OpenTimestamp, OpenTimestampUpgradeVerifyResponse } from '../types'
import { retry } from './retry.ts'
import { bytesToHex } from '@noble/hashes/utils'
import { utf8Encoder } from 'nostr-tools/utils'
import { hexStringToUint8Array } from './string.ts'
/**
* Generates a timestamp for the provided nostr event ID.
* @returns Timestamp with its value and the nostr event ID.
*/
export const generateTimestamp = async (
nostrId: string
): Promise<OpenTimestamp | undefined> => {
try {
return {
value: await retry(() => timestamp(nostrId)),
nostrId: nostrId
}
} catch (error) {
console.error(error)
return
}
}
/**
* Attempts to upgrade (i.e. add Bitcoin blockchain attestations) and verify the provided timestamp.
* Returns the same timestamp, alongside additional information required to decide if any further
* timestamp updates are required.
* @param timestamp
*/
export const upgradeAndVerifyTimestamp = async (
timestamp: OpenTimestamp
): Promise<OpenTimestampUpgradeVerifyResponse> => {
const upgradedResult = await upgrade(timestamp)
return await verify(upgradedResult)
}
/**
* Attempts to upgrade a timestamp. If an upgrade is available,
* it will add new data to detachedTimestamp.
* The upgraded flag indicates if an upgrade has been performed.
* @param t - timestamp
*/
export const upgrade = async (
t: OpenTimestamp
): Promise<OpenTimestampUpgradeVerifyResponse> => {
const detachedTimestamp =
window.OpenTimestamps.DetachedTimestampFile.deserialize(
hexStringToUint8Array(t.value)
)
const changed: boolean =
await window.OpenTimestamps.upgrade(detachedTimestamp)
if (changed) {
const updated = detachedTimestamp.serializeToBytes()
const value = {
...t,
timestamp: bytesToHex(updated)
}
return {
timestamp: value,
upgraded: true
}
}
return {
timestamp: t,
upgraded: false
}
}
/**
* Attempts to verify a timestamp. If verification is available,
* it will be included in the returned object.
* @param t - timestamp
*/
export const verify = async (
t: OpenTimestampUpgradeVerifyResponse
): Promise<OpenTimestampUpgradeVerifyResponse> => {
const detachedNostrId = window.OpenTimestamps.DetachedTimestampFile.fromBytes(
new window.OpenTimestamps.Ops.OpSHA256(),
utf8Encoder.encode(t.timestamp.nostrId)
)
const detachedTimestamp =
window.OpenTimestamps.DetachedTimestampFile.deserialize(
hexStringToUint8Array(t.timestamp.value)
)
const res = await window.OpenTimestamps.verify(
detachedTimestamp,
detachedNostrId
)
return {
...t,
verified: !!res.bitcoin,
verification: res?.bitcoin || null
}
}
/**
* Timestamps a nostrId.
* @param nostrId
*/
const timestamp = async (nostrId: string): Promise<string> => {
const detachedTimestamp =
window.OpenTimestamps.DetachedTimestampFile.fromBytes(
new window.OpenTimestamps.Ops.OpSHA256(),
utf8Encoder.encode(nostrId)
)
await window.OpenTimestamps.stamp(detachedTimestamp)
const ctx = new window.OpenTimestamps.Context.StreamSerialization()
detachedTimestamp.serialize(ctx)
const timestampBytes = ctx.getOutput()
return bytesToHex(timestampBytes)
}

View File

@ -1,4 +1,4 @@
import { PdfPage } from '../types/drawing.ts'
import { MarkType, PdfPage } from '../types/drawing.ts'
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
import { Mark } from '../types/mark.ts'
import * as PDFJS from 'pdfjs-dist'
@ -11,6 +11,9 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) {
import fontkit from '@pdf-lib/fontkit'
import defaultFont from '../assets/fonts/roboto-regular.ttf'
import { BasicPoint } from 'signature_pad/dist/types/point'
import SignaturePad from 'signature_pad'
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from './const.ts'
/**
* Defined font size used when generating a PDF. Currently it is difficult to fully
@ -132,9 +135,18 @@ export const addMarks = async (
for (let i = 0; i < pages.length; i++) {
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
marksPerPage[i]?.forEach((mark) =>
drawMarkText(mark, pages[i], robotoFont)
)
for (let j = 0; j < marksPerPage[i].length; j++) {
const mark = marksPerPage[i][j]
switch (mark.type) {
case MarkType.SIGNATURE:
await embedSignaturePng(mark, pages[i], pdf)
break
default:
drawMarkText(mark, pages[i], robotoFont)
break
}
}
}
}
@ -245,3 +257,42 @@ async function embedFont(pdf: PDFDocument) {
const embeddedFont = await pdf.embedFont(fontBytes)
return embeddedFont
}
const embedSignaturePng = async (
mark: Mark,
page: PDFPage,
pdf: PDFDocument
) => {
const { location } = mark
const { height } = page.getSize()
if (hasValue(mark)) {
const data = JSON.parse(mark.value!).map((p: BasicPoint[]) => ({
points: p
}))
const canvas = document.createElement('canvas')
canvas.width = SIGNATURE_PAD_SIZE.width
canvas.height = SIGNATURE_PAD_SIZE.height
const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS)
pad.fromData(data)
const signatureImage = await pdf.embedPng(pad.toDataURL())
const scaled = signatureImage.scaleToFit(location.width, location.height)
// Convert the mark location origin (top, left) to PDF origin (bottom, left)
// and center the image
const x = location.left + (location.width - scaled.width) / 2
const y =
height -
location.top -
location.height +
(location.height - scaled.height) / 2
page.drawImage(signatureImage, {
x,
y,
width: scaled.width,
height: scaled.height
})
}
}

25
src/utils/retry.ts Normal file
View File

@ -0,0 +1,25 @@
export const retryAll = async <T>(
promises: (() => Promise<T>)[],
retries: number = 3,
delay: number = 1000
) => {
const wrappedPromises = promises.map((fn) => retry(fn, retries, delay))
return Promise.allSettled(wrappedPromises)
}
export const retry = async <T>(
fn: () => Promise<T>,
retries: number = 3,
delay: number = 1000
): Promise<T> => {
try {
return await fn()
} catch (err) {
if (retries === 0) {
return Promise.reject(err)
}
return new Promise((resolve) =>
setTimeout(() => resolve(retry(fn, retries - 1)), delay)
)
}
}

View File

@ -2,6 +2,18 @@ import { TimeoutError } from '../types/errors/TimeoutError.ts'
import { CurrentUserFile } from '../types/file.ts'
import { SigitFile } from './file.ts'
export const debounceCustom = <T extends (...args: never[]) => void>(
fn: T,
delay: number
): ((...args: Parameters<T>) => void) => {
let timerId: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timerId)
timerId = setTimeout(() => fn(...args), delay)
}
}
export const compareObjects = (
obj1: object | null | undefined,
obj2: object | null | undefined
@ -119,3 +131,15 @@ export const settleAllFullfilfedPromises = async <Item, FulfilledItem = Item>(
return acc
}, [])
}
export const isPromiseFulfilled = <T>(
result: PromiseSettledResult<T>
): result is PromiseFulfilledResult<T> => {
return result.status === 'fulfilled'
}
export const isPromiseRejected = <T>(
result: PromiseSettledResult<T>
): result is PromiseRejectedResult => {
return result.status === 'rejected'
}

View File

@ -1,9 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [react(), tsconfigPaths()],
plugins: [
react(),
tsconfigPaths(),
nodePolyfills({
include: ['os']
})
],
build: {
target: 'ES2022'
}