Send notifications with blossom url to meta.json #276

Merged
b merged 7 commits from 260-meta-blossom into staging 2024-12-18 11:46:57 +00:00
11 changed files with 253 additions and 43 deletions

14
package-lock.json generated
View File

@ -3587,10 +3587,11 @@
"dev": true
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -6254,9 +6255,9 @@
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true,
"funding": [
{
@ -6264,6 +6265,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},

View File

@ -1,5 +1,4 @@
import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import {
findNextIncompleteCurrentUserMark,
getToolboxLabelByMarkType,
@ -10,6 +9,8 @@ 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'
import { Button } from '@mui/material'
import styles from './style.module.scss'
interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[]
@ -123,22 +124,25 @@ const MarkFormField = ({
userMark={selectedMark}
/>
<div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}>
<Button type="submit" className={styles.submitButton}>
NEXT
</button>
</Button>
</div>
</form>
)}
{complete && (
<div className={styles.actionsBottom}>
<button
<Button
onClick={handleSignAndComplete}
className={styles.submitButton}
className={[styles.submitButton, styles.completeButton].join(
' '
)}
disabled={!isReadyToSign()}
autoFocus
>
SIGN AND COMPLETE
</button>
</Button>
</div>
)}
@ -148,6 +152,7 @@ const MarkFormField = ({
return (
<div className={styles.pagination} key={index}>
<button
type="button"
className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
onClick={() => handleCurrentUserMarkClick(mark)}
>
@ -161,8 +166,10 @@ const MarkFormField = ({
})}
<div className={styles.pagination}>
<button
type="button"
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
onClick={handleSelectCompleteMark}
title="Complete"
>
<FontAwesomeIcon
className={styles.finishPage}

View File

@ -70,6 +70,11 @@
margin-top: 10px;
}
.completeButton {
font-size: 18px;
padding: 10px 20px;
}
.paginationButton {
font-size: 12px;
padding: 5px 10px;
@ -78,7 +83,8 @@
color: rgba(0, 0, 0, 0.5);
}
.paginationButton:hover {
.paginationButton:hover,
.paginationButton:focus {
background: #447592;
color: rgba(255, 255, 255, 0.5);
}

View File

@ -45,7 +45,7 @@ export interface FlatMeta
isValid: boolean
// Decryption
encryptionKey: string | null
encryptionKey: string | undefined
// Parsed Document Signatures
parsedSignatureEvents: {
@ -101,7 +101,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
[signer: `npub1${string}`]: SignStatus
}>({})
const [encryptionKey, setEncryptionKey] = useState<string | null>(null)
const [encryptionKey, setEncryptionKey] = useState<string | undefined>()
useEffect(() => {
if (!meta) return
@ -143,7 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig)
setZipUrl(zipUrl)
let encryptionKey: string | null = null
let encryptionKey: string | undefined
if (meta.keys) {
const { sender, keys } = meta.keys
// Retrieve the user's public key from the state
@ -161,7 +161,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
'An error occurred in decrypting encryption key',
err
)
return null
return undefined
})
encryptionKey = decrypted

View File

@ -31,6 +31,7 @@ import {
KeyboardCode,
Meta,
ProfileMetadata,
SigitNotification,
SignedEvent,
User,
UserRole
@ -53,7 +54,8 @@ import {
uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises,
DEFAULT_LOOK_UP_RELAY_LIST
DEFAULT_LOOK_UP_RELAY_LIST,
uploadMetaToFileStorage
} from '../../utils'
import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss'
@ -820,7 +822,7 @@ export const CreatePage = () => {
}
// Send notifications to signers and viewers
const sendNotifications = (meta: Meta) => {
const sendNotifications = (notification: SigitNotification) => {
// no need to send notification to self so remove it from the list
const receivers = (
signers.length > 0
@ -828,7 +830,7 @@ export const CreatePage = () => {
: viewers.map((viewer) => viewer.pubkey)
).filter((receiver) => receiver !== usersPubkey)
return receivers.map((receiver) => sendNotification(receiver, meta))
return receivers.map((receiver) => sendNotification(receiver, notification))
}
const extractNostrId = (stringifiedEvent: string): string => {
@ -903,11 +905,17 @@ export const CreatePage = () => {
}
setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta)
if (!event) return
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications(meta)
const promises = sendNotifications({
metaUrl,
keys: meta.keys
})
await Promise.all(promises)
.then(() => {

View File

@ -37,7 +37,8 @@ import {
timeout,
unixNow,
updateMarks,
updateUsersAppData
updateUsersAppData,
uploadMetaToFileStorage
} from '../../utils'
import { CurrentUserMark, Mark } from '../../types/mark.ts'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
@ -519,7 +520,7 @@ export const SignPage = () => {
}
if (await isOnline()) {
await handleOnlineFlow(updatedMeta)
await handleOnlineFlow(updatedMeta, encryptionKey)
} else {
setMeta(updatedMeta)
setIsLoading(false)
@ -639,8 +640,11 @@ export const SignPage = () => {
return null
}
// Handle the online flow: update users' app data and send notifications
const handleOnlineFlow = async (meta: Meta) => {
// Handle the online flow: update users app data and send notifications
const handleOnlineFlow = async (
meta: Meta,
encryptionKey: string | undefined
) => {
setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta)
if (!updatedEvent) {
@ -648,6 +652,18 @@ export const SignPage = () => {
return
}
let metaUrl: string | undefined
try {
metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
return
}
const userSet = new Set<`npub1${string}`>()
if (submittedBy && submittedBy !== usersPubkey) {
userSet.add(hexToNpub(submittedBy))
@ -680,7 +696,7 @@ export const SignPage = () => {
setLoadingSpinnerDesc('Sending notifications')
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, meta)
sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys })
)
await Promise.all(promises)
.then(() => {

View File

@ -28,7 +28,8 @@ import {
encryptArrayBuffer,
generateKeysFile,
ARRAY_BUFFER,
DEFLATE
DEFLATE,
uploadMetaToFileStorage
} from '../../utils'
import styles from './style.module.scss'
import { useLocation, useParams } from 'react-router-dom'
@ -356,6 +357,11 @@ export const VerifyPage = () => {
const updatedEvent = await updateUsersAppData(updatedMeta)
if (!updatedEvent) return
const metaUrl = await uploadMetaToFileStorage(
updatedMeta,
encryptionKey
)
const userSet = new Set<`npub1${string}`>()
signers.forEach((signer) => {
if (signer !== usersPubkey) {
@ -369,7 +375,10 @@ export const VerifyPage = () => {
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, updatedMeta)
sendNotification(npubToHex(user)!, {
metaUrl,
keys: meta.keys!
})
)
await Promise.all(promises)

View File

@ -83,3 +83,12 @@ export interface UserAppData {
export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent
}
export interface SigitNotification {
metaUrl: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
}
export function isSigitNotification(obj: unknown): obj is SigitNotification {
return typeof (obj as SigitNotification).metaUrl === 'string'
}

View File

@ -0,0 +1,26 @@
import { Jsonable } from '.'
export enum MetaStorageErrorType {
'ENCRYPTION_KEY_REQUIRED' = 'Encryption key is required.',
'HASHING_FAILED' = "Can't get encrypted file hash.",
'FETCH_FAILED' = 'Fetching meta.json requires an encryption key.',
'HASH_VERIFICATION_FAILED' = 'Unable to verify meta.json.',
'DECRYPTION_FAILED' = 'Error decryping meta.json.',
'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.'
}
export class MetaStorageError extends Error {
public readonly context?: Jsonable
constructor(
message: MetaStorageErrorType,
options: { cause?: Error; context?: Jsonable } = {}
) {
const { cause, context } = options
super(message, { cause })
this.name = this.constructor.name
this.context = context
}
}

View File

@ -1,5 +1,12 @@
import { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.'
import {
decryptArrayBuffer,
encryptArrayBuffer,
fromUnixTimestamp,
getHash,
parseJson,
uploadToFileStorage
} from '.'
import { Event, verifyEvent } from 'nostr-tools'
import { toast } from 'react-toastify'
import { extractFileExtensions } from './file'
@ -8,6 +15,11 @@ import {
MetaParseError,
MetaParseErrorType
} from '../types/errors/MetaParseError'
import axios from 'axios'
import {
MetaStorageError,
MetaStorageErrorType
} from '../types/errors/MetaStorageError'
export enum SignStatus {
Signed = 'Signed',
@ -126,3 +138,76 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
}
}
}
export const uploadMetaToFileStorage = async (
meta: Meta,
encryptionKey: string | undefined
) => {
// Value is the stringified meta object
const value = JSON.stringify(meta)
const encoder = new TextEncoder()
// Encode it to the arrayBuffer
const uint8Array = encoder.encode(value)
if (!encryptionKey) {
throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED)
}
// 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 MetaStorageError(MetaStorageErrorType.HASHING_FAILED)
}
// Create the encrypted json file from array buffer and hash
const file = new File([encryptedArrayBuffer], `${hash}.json`)
const url = await uploadToFileStorage(file)
return url
}
export const fetchMetaFromFileStorage = async (
url: string,
encryptionKey: string | undefined
) => {
if (!encryptionKey) {
throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED)
}
const encryptedArrayBuffer = await axios.get(url, {
responseType: 'arraybuffer'
})
// Verify hash
const parts = url.split('/')
const urlHash = parts[parts.length - 1]
const hash = await getHash(encryptedArrayBuffer.data)
if (hash !== urlHash) {
throw new MetaStorageError(MetaStorageErrorType.HASH_VERIFICATION_FAILED)
}
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer.data,
encryptionKey
).catch((err) => {
throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, {
cause: err
})
})
if (arrayBuffer) {
// Decode meta.json and parse
const decoder = new TextDecoder()
const json = decoder.decode(arrayBuffer)
const meta = await parseJson<Meta>(json)
return meta
}
throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR)
}

View File

@ -29,12 +29,20 @@ import {
} from '../store/actions'
import { Keys } from '../store/auth/types'
import store from '../store/store'
import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types'
import {
isSigitNotification,
Meta,
ProfileMetadata,
SigitNotification,
SignedEvent,
UserAppData
} from '../types'
import { getDefaultRelayMap } from './relays'
import { parseJson, removeLeadingSlash } from './string'
import { timeout } from './utils'
import { getHash } from './hash'
import { SIGIT_BLOSSOM } from './const.ts'
import { fetchMetaFromFileStorage } from './meta.ts'
/**
* Generates a `d` tag for userAppData
@ -908,17 +916,48 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return
const meta = await parseJson<Meta>(internalUnsignedEvent.content).catch(
(err) => {
console.log(
'An error occurred in parsing the internal unsigned event',
err
)
return null
}
)
const parsedContent = await parseJson<Meta | SigitNotification>(
internalUnsignedEvent.content
).catch((err) => {
console.log('An error occurred in parsing the internal unsigned event', err)
return null
})
if (!meta) return
if (!parsedContent) return
let meta: Meta
if (isSigitNotification(parsedContent)) {
const notification = parsedContent
let encryptionKey: string | undefined
if (!notification.keys) return
const { sender, keys } = notification.keys
// Retrieve the user's public key from the state
const usersPubkey = store.getState().auth.usersPubkey!
const usersNpub = hexToNpub(usersPubkey)
// Check if the user's public key is in the keys object
if (usersNpub in keys) {
// Instantiate the NostrController to decrypt the encryption key
const nostrController = NostrController.getInstance()
const decrypted = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
console.log('An error occurred in decrypting encryption key', err)
return undefined
})
encryptionKey = decrypted
}
try {
meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey)
} catch (error) {
console.error(`An error occured fetching meta file from storage`, error)
return
}
} else {
meta = parsedContent
}
await updateUsersAppData(meta)
}
@ -926,9 +965,12 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
/**
* Function to send a notification to a specified receiver.
* @param receiver - The recipient's public key.
* @param meta - Metadata associated with the notification.
* @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt.
*/
export const sendNotification = async (receiver: string, meta: Meta) => {
export const sendNotification = async (
receiver: string,
notification: SigitNotification
) => {
// Retrieve the user's public key from the state
const usersPubkey = store.getState().auth.usersPubkey!
@ -936,7 +978,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
const unsignedEvent: UnsignedEvent = {
kind: 938,
pubkey: usersPubkey,
content: JSON.stringify(meta),
content: JSON.stringify(notification),
tags: [],
created_at: unixNow()
}