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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -83,3 +83,12 @@ export interface UserAppData {
export interface DocSignatureEvent extends Event { export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent 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 { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.' import {
decryptArrayBuffer,
encryptArrayBuffer,
fromUnixTimestamp,
getHash,
parseJson,
uploadToFileStorage
} from '.'
import { Event, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { extractFileExtensions } from './file' import { extractFileExtensions } from './file'
@ -8,6 +15,11 @@ import {
MetaParseError, MetaParseError,
MetaParseErrorType MetaParseErrorType
} from '../types/errors/MetaParseError' } from '../types/errors/MetaParseError'
import axios from 'axios'
import {
MetaStorageError,
MetaStorageErrorType
} from '../types/errors/MetaStorageError'
export enum SignStatus { export enum SignStatus {
Signed = 'Signed', 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' } from '../store/actions'
import { Keys } from '../store/auth/types' import { Keys } from '../store/auth/types'
import store from '../store/store' 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 { getDefaultRelayMap } from './relays'
import { parseJson, removeLeadingSlash } from './string' import { parseJson, removeLeadingSlash } from './string'
import { timeout } from './utils' import { timeout } from './utils'
import { getHash } from './hash' import { getHash } from './hash'
import { SIGIT_BLOSSOM } from './const.ts' import { SIGIT_BLOSSOM } from './const.ts'
import { fetchMetaFromFileStorage } from './meta.ts'
/** /**
* Generates a `d` tag for userAppData * Generates a `d` tag for userAppData
@ -908,17 +916,48 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return
const meta = await parseJson<Meta>(internalUnsignedEvent.content).catch( const parsedContent = await parseJson<Meta | SigitNotification>(
(err) => { internalUnsignedEvent.content
console.log( ).catch((err) => {
'An error occurred in parsing the internal unsigned event', console.log('An error occurred in parsing the internal unsigned event', err)
err return null
) })
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) await updateUsersAppData(meta)
} }
@ -926,9 +965,12 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
/** /**
* Function to send a notification to a specified receiver. * Function to send a notification to a specified receiver.
* @param receiver - The recipient's public key. * @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 // Retrieve the user's public key from the state
const usersPubkey = store.getState().auth.usersPubkey! const usersPubkey = store.getState().auth.usersPubkey!
@ -936,7 +978,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
const unsignedEvent: UnsignedEvent = { const unsignedEvent: UnsignedEvent = {
kind: 938, kind: 938,
pubkey: usersPubkey, pubkey: usersPubkey,
content: JSON.stringify(meta), content: JSON.stringify(notification),
tags: [], tags: [],
created_at: unixNow() created_at: unixNow()
} }