diff --git a/docs/blossom-flow.drawio b/docs/blossom-flow.drawio index 1cfee04..28b9198 100644 --- a/docs/blossom-flow.drawio +++ b/docs/blossom-flow.drawio @@ -1,6 +1,6 @@ - + - + @@ -61,44 +61,119 @@ - + - + - - + + - - + + - + - - + + - - + + - + - - + + - + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/MarkTypeStrategy/Signature/index.tsx b/src/components/MarkTypeStrategy/Signature/index.tsx index 5915ab2..57b19e6 100644 --- a/src/components/MarkTypeStrategy/Signature/index.tsx +++ b/src/components/MarkTypeStrategy/Signature/index.tsx @@ -39,9 +39,13 @@ export const SignatureStrategy: MarkStrategy = { if (await isOnline()) { try { - const url = await uploadToFileStorage(file) - console.info(`${file.name} uploaded to file storage`) - return url + const urls = await uploadToFileStorage(file) + console.info( + `${file.name} uploaded to following file storages: ${urls.join(', ')}` + ) + // This bit was returning an url, and return of this function is being set to mark.value, so it kind of + // does not make sense to return an url to the file storage + return value } catch (error) { if (error instanceof Error) { console.error( @@ -51,7 +55,7 @@ export const SignatureStrategy: MarkStrategy = { } } } else { - // TOOD: offline + // TODO: offline } return value diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 5c1159e..77d21c7 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -86,7 +86,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { }>({}) const [markConfig, setMarkConfig] = useState([]) const [title, setTitle] = useState('') - const [zipUrl, setZipUrl] = useState('') + const [zipUrls, setZipUrls] = useState([]) const [parsedSignatureEvents, setParsedSignatureEvents] = useState<{ [signer: `npub1${string}`]: DocSignatureEvent @@ -133,7 +133,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setId(id) setSig(sig) - const { title, signers, viewers, fileHashes, markConfig, zipUrl } = + const { title, signers, viewers, fileHashes, markConfig, zipUrls } = await parseCreateSignatureEventContent(content) setTitle(title) @@ -141,7 +141,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setViewers(viewers) setFileHashes(fileHashes) setMarkConfig(markConfig) - setZipUrl(zipUrl) + setZipUrls(zipUrls) let encryptionKey: string | undefined if (meta.keys) { @@ -322,7 +322,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { fileHashes, markConfig, title, - zipUrl, + zipUrls, parsedSignatureEvents, completedAt, signedStatus, diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index f1ba4ad..31f5967 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -55,7 +55,8 @@ import { DEFAULT_TOOLBOX, settleAllFullfilfedPromises, DEFAULT_LOOK_UP_RELAY_LIST, - uploadMetaToFileStorage + uploadMetaToFileStorage, + isValidNip05 } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -264,8 +265,7 @@ export const CreatePage = () => { // 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)) { + if (isValidNip05(userSearchInput)) { setSearchUsersLoading(true) const pubkey = await handleSearchUserNip05(userSearchInput) @@ -756,10 +756,10 @@ export const CreatePage = () => { return null } - // Upload the file to the storage - const uploadFile = async ( + // Upload the file to the storage/s + const uploadFiles = async ( arrayBuffer: ArrayBuffer - ): Promise => { + ): Promise => { const blob = new Blob([arrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed-${unixNow()}.sigit`, { @@ -818,14 +818,14 @@ export const CreatePage = () => { fileHashes: { [key: string]: string }, - zipUrl: string + zipUrls: string[] ) => { const content: CreateSignatureEventContent = { signers: signers.map((signer) => hexToNpub(signer.pubkey)), viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), fileHashes, markConfig, - zipUrl, + zipUrls, title } @@ -888,15 +888,15 @@ export const CreatePage = () => { const markConfig = createMarks(fileHashes) - setLoadingSpinnerDesc('Uploading files.zip to file storage') - const fileUrl = await uploadFile(encryptedArrayBuffer) - if (!fileUrl) return + setLoadingSpinnerDesc('Uploading files.zip to file storages') + const fileUrls = await uploadFiles(encryptedArrayBuffer) + if (!fileUrls) return setLoadingSpinnerDesc('Generating create signature') const createSignature = await generateCreateSignature( markConfig, fileHashes, - fileUrl + fileUrls ) if (!createSignature) return @@ -934,11 +934,11 @@ export const CreatePage = () => { const event = await updateUsersAppData(meta) if (!event) return - const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + const metaUrls = await uploadMetaToFileStorage(meta, encryptionKey) setLoadingSpinnerDesc('Sending notifications to counterparties') const promises = sendNotifications({ - metaUrl, + metaUrls, keys: meta.keys }) @@ -964,7 +964,7 @@ export const CreatePage = () => { const createSignature = await generateCreateSignature( markConfig, fileHashes, - '' + [] ) if (!createSignature) return diff --git a/src/pages/settings/relays/index.tsx b/src/pages/settings/relays/index.tsx index a1f5223..2e4233b 100644 --- a/src/pages/settings/relays/index.tsx +++ b/src/pages/settings/relays/index.tsx @@ -23,6 +23,7 @@ import { getRelayInfo, getRelayMap, hexToNpub, + isValidRelayUri, publishRelayMap, shorten } from '../../../utils' @@ -149,12 +150,7 @@ export const RelaysPage = () => { const relayURI = `${webSocketPrefix}${newRelayURI?.trim().replace(webSocketPrefix, '')}` // Check if new relay URI is a valid string - if ( - relayURI && - !/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( - relayURI - ) - ) { + if (relayURI && !isValidRelayUri(relayURI)) { if (relayURI !== webSocketPrefix) { setNewRelayURIerror( 'New relay URI is not valid. Example of valid relay URI: wss://sigit.relay.io' diff --git a/src/pages/settings/servers/index.tsx b/src/pages/settings/servers/index.tsx index 6160518..4279d26 100644 --- a/src/pages/settings/servers/index.tsx +++ b/src/pages/settings/servers/index.tsx @@ -22,7 +22,7 @@ import { } from '../../../utils/file-servers.ts' import { useAppSelector } from '../../../hooks' import { useDidMount } from '../../../hooks' -import { SIGIT_BLOSSOM } from '../../../utils' +import { isValidUrl } from '../../../utils' import axios from 'axios' import { cloneDeep } from 'lodash' @@ -79,11 +79,7 @@ export const ServersPage = () => { if (!serverURL) return // Check if new server is a valid URL - if ( - !/^(https?:\/\/)(?!-)([A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,6}$/gim.test( - serverURL - ) - ) { + if (!isValidUrl(serverURL)) { if (serverURL !== protocol) { setNewRelayURLerror(errors.urlNotValid) return @@ -109,10 +105,7 @@ export const ServersPage = () => { } const handleDeleteServer = (serverURL: string) => { - if ( - serverURL === SIGIT_BLOSSOM && - Object.keys(blossomServersMap).length === 1 - ) + if (Object.keys(blossomServersMap).length === 1) return serverRequirementWarning() // Remove server from the list @@ -213,6 +206,7 @@ export const ServersPage = () => { ))} @@ -225,10 +219,15 @@ export const ServersPage = () => { interface ServerItemProps { serverURL: string + preventDelete?: boolean handleDeleteServer?: (serverURL: string) => void } -const ServerItem = ({ serverURL, handleDeleteServer }: ServerItemProps) => { +const ServerItem = ({ + serverURL, + handleDeleteServer, + preventDelete +}: ServerItemProps) => { return ( @@ -244,7 +243,7 @@ const ServerItem = ({ serverURL, handleDeleteServer }: ServerItemProps) => { handleDeleteServer && handleDeleteServer(serverURL)} - className={`${styles.leaveServerContainer} ${serverURL === SIGIT_BLOSSOM ? styles.disabled : ''}`} + className={`${styles.leaveServerContainer} ${preventDelete ? styles.disabled : ''}`} > Remove diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index fe9d047..d226243 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -287,26 +287,42 @@ export const SignPage = () => { return } - const { zipUrl, encryptionKey } = res + const { zipUrls, encryptionKey } = res - setLoadingSpinnerDesc('Fetching file from file server') - axios - .get(zipUrl, { - responseType: 'arraybuffer' - }) - .then((res) => { + for (let i = 0; i < zipUrls.length; i++) { + const zipUrl = zipUrls[i] + const isLastZipUrl = i === zipUrls.length - 1 + + setLoadingSpinnerDesc('Fetching file from file server') + + const res = await axios + .get(zipUrl, { + responseType: 'arraybuffer' + }) + .catch((err) => { + console.error( + `error occurred in getting file from ${zipUrls}`, + err + ) + toast.error( + err.message || `error occurred in getting file from ${zipUrls}` + ) + return null + }) + + setIsLoading(false) + + if (res) { handleArrayBufferFromBlossom(res.data, encryptionKey) setMeta(metaInNavState) - }) - .catch((err) => { - console.error(`error occurred in getting file from ${zipUrl}`, err) - toast.error( - err.message || `error occurred in getting file from ${zipUrl}` - ) - }) - .finally(() => { - setIsLoading(false) - }) + break + } else { + // No data returned, break from the loop + if (isLastZipUrl) { + break + } + } + } } processSigit() @@ -471,6 +487,10 @@ export const SignPage = () => { setMeta(parsedMetaJson) } + /** + * Start the signing process + * When user signs, files will automatically be published to all user preferred servers + */ const handleSign = async () => { if (Object.entries(files).length === 0 || !meta) return @@ -652,9 +672,9 @@ export const SignPage = () => { return } - let metaUrl: string + let metaUrls: string[] try { - metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + metaUrls = await uploadMetaToFileStorage(meta, encryptionKey) } catch (error) { if (error instanceof Error) { toast.error(error.message) @@ -696,7 +716,10 @@ export const SignPage = () => { setLoadingSpinnerDesc('Sending notifications') const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys }) + sendNotification(npubToHex(user)!, { + metaUrls: metaUrls, + keys: meta.keys + }) ) await Promise.all(promises) .then(() => { diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 2ea8164..3831f97 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -216,7 +216,7 @@ export const VerifyPage = () => { const { submittedBy, - zipUrl, + zipUrls, encryptionKey, signers, viewers, @@ -376,7 +376,7 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => sendNotification(npubToHex(user)!, { - metaUrl, + metaUrls: metaUrl, keys: meta.keys! }) ) @@ -403,35 +403,55 @@ export const VerifyPage = () => { const processSigit = async () => { setIsLoading(true) + // We have multiple zipUrls, we should fetch one by one and take the first one which successfully decrypts + // If file is altered decrytption will fail setLoadingSpinnerDesc('Fetching file from file server') - try { - const res = await axios.get(zipUrl, { - responseType: 'arraybuffer' - }) - const fileName = zipUrl.split('/').pop() - const file = new File([res.data], fileName!) + for (let i = 0; i < zipUrls.length; i++) { + const zipUrl = '' + const isLastZipUrl = i === zipUrls.length - 1 - 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 - }) + try { + // Fetch zip data + const res = await axios.get(zipUrl, { + responseType: 'arraybuffer' + }) - if (arrayBuffer) { + // Prepare file from response + const fileName = zipUrl.split('/').pop() + const file = new File([res.data], fileName!) + const encryptedArrayBuffer = await file.arrayBuffer() + + // Decrypt the array buffer + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer, + encryptionKey + ).catch((err) => { + console.error('Error in decryption:>> ', err) + toast.error( + err.message || 'An error occurred in decrypting file.' + ) + return null // Continue iteration for next zipUrl + }) + + if (!arrayBuffer) { + if (!isLastZipUrl) continue // Skip to next zipUrl if decryption fails + break // If last zipUrl break out of loop + } + + // Load zip archive const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => { - console.log('err in loading zip file :>> ', err) + console.error('Error in loading zip file :>> ', err) toast.error( err.message || 'An error occurred in loading zip file.' ) - return null + return null // Skip to next zipUrl }) - if (!zip) return + if (!zip) { + if (!isLastZipUrl) continue // Skip to next zipUrl + break // If last zipUrl break out of loop + } const files: { [fileName: string]: SigitFile } = {} const fileHashes: { [key: string]: string | null } = {} @@ -439,47 +459,44 @@ export const VerifyPage = () => { (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( + // Generate hashes for all entries in the files folder of zipArchive + for (const entryFileName of fileNames) { + const entryArrayBuffer = await readContentOfZipEntry( zip, - fileName, + entryFileName, 'arraybuffer' ) - - if (arrayBuffer) { - files[fileName] = await convertToSigitFile( - arrayBuffer, - fileName! + if (entryArrayBuffer) { + files[entryFileName] = await convertToSigitFile( + entryArrayBuffer, + entryFileName ) - const hash = await getHash(arrayBuffer) - + const hash = await getHash(entryArrayBuffer) if (hash) { - fileHashes[fileName.replace(/^files\//, '')] = hash + fileHashes[entryFileName.replace(/^files\//, '')] = hash } } else { - fileHashes[fileName.replace(/^files\//, '')] = null + fileHashes[entryFileName.replace(/^files\//, '')] = null } } 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) } - } 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() } - }, [encryptionKey, metaInNavState, zipUrl]) + }, [encryptionKey, metaInNavState, zipUrls]) const handleVerify = async () => { if (!selectedFile) return diff --git a/src/store/userAppData/reducer.ts b/src/store/userAppData/reducer.ts index 22c7cb0..40e294a 100644 --- a/src/store/userAppData/reducer.ts +++ b/src/store/userAppData/reducer.ts @@ -5,7 +5,7 @@ import { UserAppDataDispatchTypes } from './types' const initialState: UserAppData = { sigits: {}, processedGiftWraps: [], - blossomUrls: [] + blossomVersions: [] } const reducer = ( diff --git a/src/types/core.ts b/src/types/core.ts index 3b2af54..1c2a49b 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -19,7 +19,6 @@ export interface Meta { exportSignature?: string keys?: { sender: string; keys: { [user: `npub1${string}`]: string } } timestamps?: OpenTimestamp[] - // TODO Add field: fileServers } export interface CreateSignatureEventContent { @@ -28,7 +27,7 @@ export interface CreateSignatureEventContent { fileHashes: { [key: string]: string } markConfig: Mark[] title: string - zipUrl: string + zipUrls: string[] } export interface SignedEventContent { @@ -76,9 +75,14 @@ export interface UserAppData { */ keyPair?: Keys /** - * Array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom. + * Array for storing Urls for the files which stores all sigits and processedGiftWraps on file servers (blossom). + * We keep the last 10 versions */ - blossomUrls: string[] + blossomVersions: BlossomVersion[] +} + +export interface BlossomVersion { + urls: string[] } export interface DocSignatureEvent extends Event { @@ -86,10 +90,10 @@ export interface DocSignatureEvent extends Event { } export interface SigitNotification { - metaUrl: string + metaUrls: string[] keys?: { sender: string; keys: { [user: `npub1${string}`]: string } } } export function isSigitNotification(obj: unknown): obj is SigitNotification { - return typeof (obj as SigitNotification).metaUrl === 'string' + return typeof (obj as SigitNotification).metaUrls === 'object' } diff --git a/src/types/errors/MetaStorageError.ts b/src/types/errors/MetaStorageError.ts index a5bc2cd..066a53a 100644 --- a/src/types/errors/MetaStorageError.ts +++ b/src/types/errors/MetaStorageError.ts @@ -6,7 +6,8 @@ export enum MetaStorageErrorType { '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.' + 'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.', + 'NO_URLS_PROCESSED_SUCCESSFULLY' = 'No URLs were available to process.' } export class MetaStorageError extends Error { diff --git a/src/utils/meta.ts b/src/utils/meta.ts index 8052abf..714eb2c 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -167,47 +167,72 @@ export const uploadMetaToFileStorage = async ( // Create the encrypted json file from array buffer and hash const file = new File([encryptedArrayBuffer], `${hash}.json`) - const url = await uploadToFileStorage(file) + const urls = await uploadToFileStorage(file) - return url + return urls } +/** + * Fetches the meta from one or more file storages, one by one, and it will take the first one, which has matching hash + * @param urls urls of meta files + * @param encryptionKey + */ export const fetchMetaFromFileStorage = async ( - url: string, + urls: string[], encryptionKey: string | undefined -) => { +): Promise => { 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 + for (let i = 0; i < urls.length; i++) { + const url = urls[i] + const isLastUrl = i === urls.length - 1 + const encryptedArrayBuffer = await axios.get(url, { + responseType: 'arraybuffer' }) - }) - if (arrayBuffer) { - // Decode meta.json and parse - const decoder = new TextDecoder() - const json = decoder.decode(arrayBuffer) - const meta = await parseJson(json) - return meta + // Verify hash + const parts = url.split('/') + const urlHash = parts[parts.length - 1] + const hash = await getHash(encryptedArrayBuffer.data) + if (hash !== urlHash) { + // If no more urls left to try and hash check failed, throw an error + if (isLastUrl) + throw new MetaStorageError( + MetaStorageErrorType.HASH_VERIFICATION_FAILED + ) + // Otherwise, skip to the next url to fetch + continue + } + + const arrayBuffer = await decryptArrayBuffer( + encryptedArrayBuffer.data, + encryptionKey + ).catch((err) => { + if (isLastUrl) { + throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, { + cause: err + }) + } else { + return null + } + }) + + if (arrayBuffer) { + // Decode meta.json and parse + const decoder = new TextDecoder() + const json = decoder.decode(arrayBuffer) + const meta = await parseJson(json) + return meta + } else if (!isLastUrl) { + continue + } + + throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR) } - throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR) + throw new MetaStorageError( + MetaStorageErrorType.NO_URLS_PROCESSED_SUCCESSFULLY + ) } diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 3942a5b..bf1f4e8 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,4 +1,4 @@ -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import { Event, EventTemplate, @@ -11,7 +11,11 @@ import { import { toast } from 'react-toastify' import { NostrController } from '../controllers' import store from '../store/store' -import { CreateSignatureEventContent, Meta } from '../types' +import { + CreateSignatureEventContent, + FileServerPutResponse, + Meta +} from '../types' import { hexToNpub, unixNow } from './nostr' import { parseJson } from './string' import { hexToBytes } from '@noble/hashes/utils' @@ -19,18 +23,23 @@ import { getHash } from './hash.ts' import { SIGIT_BLOSSOM } from './const.ts' /** - * Uploads a file to a file storage service. + * Uploads a file to one or more file storage services. * @param blob The Blob object representing the file to upload. * @param nostrController The NostrController instance for handling authentication. - * @returns The URL of the uploaded file. + * @returns The array of URLs of the uploaded file. */ -export const uploadToFileStorage = async (file: File) => { +export const uploadToFileStorage = async (file: File): Promise => { // Define event metadata for authorization const hash = await getHash(await file.arrayBuffer()) if (!hash) { throw new Error("Can't get file hash.") } + const preferredServersMap = store.getState().servers.map || {} + const preferredServers = Object.keys(preferredServersMap) + // If no servers found, use SIGIT as fallback + if (!preferredServers.length) preferredServers.push(SIGIT_BLOSSOM) + const event: EventTemplate = { kind: 24242, content: 'Authorize Upload', @@ -54,16 +63,28 @@ export const uploadToFileStorage = async (file: File) => { // Sign the authorization event using the dedicated key stored in user app data const authEvent = finalizeEvent(event, hexToBytes(key)) - // Upload the file to the file storage service using Axios - const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, { - headers: { - Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header - 'Content-Type': 'application/sigit' // Set content type header - } - }) + const uploadPromises: Promise>[] = [] - // Return the URL of the uploaded file - return response.data.url as string + // Upload the file to the file storage services using Axios + for (const preferredServer of preferredServers) { + const uploadPromise = axios.put( + `${preferredServer}/upload`, + file, + { + headers: { + Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header + 'Content-Type': 'application/sigit' // Set content type header + } + } + ) + + uploadPromises.push(uploadPromise) + } + + const responses = await Promise.all(uploadPromises) + + // Return the URLs of the uploaded files + return responses.map((response) => response.data.url) as string[] } /** @@ -228,7 +249,7 @@ export const extractZipUrlAndEncryptionKey = async (meta: Meta) => { if (!createSignatureContent) return null // Extract the ZIP URL from the create signature content - const zipUrl = createSignatureContent.zipUrl + const zipUrls = createSignatureContent.zipUrls // Retrieve the user's public key from the state const usersPubkey = store.getState().auth.usersPubkey! @@ -259,7 +280,7 @@ export const extractZipUrlAndEncryptionKey = async (meta: Meta) => { return { createSignatureEvent, createSignatureContent, - zipUrl, + zipUrls: zipUrls, encryptionKey: decrypted } } diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts index b98dabb..abf9cac 100644 --- a/src/utils/nostr.ts +++ b/src/utils/nostr.ts @@ -1,5 +1,5 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils' -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import _, { truncate } from 'lodash' import { Event, @@ -30,6 +30,8 @@ import { import { Keys } from '../store/auth/types' import store from '../store/store' import { + BlossomVersion, + FileServerPutResponse, isSigitNotification, Meta, ProfileMetadata, @@ -431,16 +433,16 @@ export const getUsersAppData = async (): Promise => { // Return null if encrypted content retrieval fails if (!encryptedContent) return null - // Handle case where the encrypted content is an empty object + // Handle a case where the encrypted content is an empty object if (encryptedContent === '{}') { - // Generate ephemeral key pair + // Generate an ephemeral key pair const secret = generateSecretKey() const pubKey = getPublicKey(secret) return { sigits: {}, processedGiftWraps: [], - blossomUrls: [], + blossomVersions: [], keyPair: { private: bytesToHex(secret), public: pubKey @@ -466,6 +468,7 @@ export const getUsersAppData = async (): Promise => { // Parse the decrypted content const parsedContent = await parseJson<{ + blossomVersions: BlossomVersion[] blossomUrls: string[] keyPair: Keys }>(decrypted).catch((err) => { @@ -481,14 +484,23 @@ export const getUsersAppData = async (): Promise => { // Return null if parsing fails if (!parsedContent) return null - const { blossomUrls, keyPair } = parsedContent + // If old property blossomUrls is found, convert it to new appraoch blossomVersions + if (parsedContent.blossomUrls) { + parsedContent.blossomVersions = parsedContent.blossomUrls.map((url) => { + return { + urls: [url] + } + }) + } + + const { blossomVersions, keyPair } = parsedContent // Return null if no blossom URLs are found - if (blossomUrls.length === 0) return null + if (blossomVersions.length === 0) return null - // Fetch additional user app data from the first blossom URL + // Fetch additional user app data from the last blossom version urls const dataFromBlossom = await getUserAppDataFromBlossom( - blossomUrls[0], + blossomVersions[0], keyPair.private ) @@ -499,7 +511,7 @@ export const getUsersAppData = async (): Promise => { // Return the final user application data return { - blossomUrls, + blossomVersions: blossomVersions, keyPair, sigits, processedGiftWraps @@ -543,9 +555,9 @@ export const updateUsersAppData = async (meta: Meta) => { if (!isUpdated) return null - const blossomUrls = [...appData.blossomUrls] + const blossomVersions = [...appData.blossomVersions] - const newBlossomUrl = await uploadUserAppDataToBlossom( + const newBlossomUrls = await uploadUserAppDataToBlossom( sigits, appData.processedGiftWraps, appData.keyPair.private @@ -560,21 +572,26 @@ export const updateUsersAppData = async (meta: Meta) => { return null }) - if (!newBlossomUrl) return null + if (!newBlossomUrls) return null - // insert new blossom url at the start of the array - blossomUrls.unshift(newBlossomUrl) + // insert new server (blossom) urls at the start of the array + blossomVersions.unshift({ + urls: newBlossomUrls + }) - // only keep last 10 blossom urls, delete older ones - if (blossomUrls.length > 10) { - const filesToDelete = blossomUrls.splice(10) - filesToDelete.forEach((url) => { - deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { - console.log( - 'An error occurred in removing old file of user app data from blossom server', - err - ) - }) + // only keep last 10 blossom versions (urls), delete older ones + // Every version can be uploaded to multiple servers + if (blossomVersions.length > 10) { + const versionsToDelete = blossomVersions.splice(10) + versionsToDelete.forEach((version) => { + for (const url of version.urls) { + deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { + console.log( + `An error occurred while removing an old file of user app data from the file server: ${url}`, + err + ) + }) + } }) } @@ -586,7 +603,7 @@ export const updateUsersAppData = async (meta: Meta) => { .nip04Encrypt( usersPubkey, JSON.stringify({ - blossomUrls, + blossomVersions: blossomVersions, keyPair: appData.keyPair }) ) @@ -656,7 +673,7 @@ export const updateUsersAppData = async (meta: Meta) => { store.dispatch( updateUserAppDataAction({ sigits, - blossomUrls, + blossomVersions: blossomVersions, processedGiftWraps: [...appData.processedGiftWraps], keyPair: { ...appData.keyPair @@ -696,7 +713,7 @@ const deleteBlossomFile = async (url: string, privateKey: string) => { } /** - * Function to upload user application data to the SIGit Blossom server. + * Function to upload user application data to the user preferred File (Blossom) servers. * @param sigits - An object containing metadata for the user application data. * @param processedGiftWraps - An array of processed gift wrap IDs. * @param privateKey - The private key used for encryption. @@ -707,6 +724,11 @@ const uploadUserAppDataToBlossom = async ( processedGiftWraps: string[], privateKey: string ) => { + const preferredServersMap = store.getState().servers.map || {} + const preferredServers = Object.keys(preferredServersMap) + // If no servers found, use SIGIT as fallback + if (!preferredServers.length) preferredServers.push(SIGIT_BLOSSOM) + // Create an object containing the sigits and processed gift wraps const obj = { sigits, @@ -752,31 +774,49 @@ const uploadUserAppDataToBlossom = async ( // Finalize the event with the private key const authEvent = finalizeEvent(event, hexToBytes(privateKey)) - // TODO send to all added preferred blossom/file servers - // Upload the file to the file storage service using Axios - const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, { - headers: { - Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header - } - }) - // Return the URL of the uploaded file - return response.data.url as string + const uploadPromises: Promise>[] = [] + + // Upload the file to the file storage services using Axios + for (const preferredServer of preferredServers) { + const uploadPromise = axios.put( + `${preferredServer}/upload`, + file, + { + headers: { + Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header + } + } + ) + + uploadPromises.push(uploadPromise) + } + + const responses = await Promise.all(uploadPromises) + + // Return the URLs of the uploaded files + return responses.map((response) => response.data.url) as string[] } /** - * Function to retrieve and decrypt user application data from Blossom server. - * @param url - The URL to fetch the encrypted data from. + * Function to retrieve and decrypt user application data from file (Blossom) servers. + * Since we pull from multiple servers, we will take the first one + * @param blossomVersion - The URL to fetch the encrypted data from. * @param privateKey - The private key used for decryption. * @returns A promise that resolves to the decrypted and parsed user application data. */ -const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { +const getUserAppDataFromBlossom = async ( + blossomVersion: BlossomVersion, + privateKey: string +) => { // Initialize errorCode to track HTTP error codes let errorCode = 0 + const blossomUrl = blossomVersion.urls[0] + // Fetch the encrypted data from the provided URL const encrypted = await axios - .get(url, { + .get(blossomUrl, { responseType: 'blob' // Expect a blob response }) .then(async (res) => { @@ -788,8 +828,13 @@ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { }) .catch((err) => { // Log and display an error message if the request fails - console.error(`error occurred in getting file from ${url}`, err) - toast.error(err.message || `error occurred in getting file from ${url}`) + console.error( + `error occurred in getting file from ${blossomVersion}`, + err + ) + toast.error( + err.message || `error occurred in getting file from ${blossomVersion}` + ) // Set errorCode to the HTTP status code if available if (err.request) { @@ -950,7 +995,10 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => { encryptionKey = decrypted } try { - meta = await fetchMetaFromFileStorage(notification.metaUrl, encryptionKey) + meta = await fetchMetaFromFileStorage( + notification.metaUrls, + encryptionKey + ) } catch (error) { console.error(`An error occured fetching meta file from storage`, error) return diff --git a/src/utils/utils.ts b/src/utils/utils.ts index bcf2960..f4de308 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,6 +1,7 @@ import { TimeoutError } from '../types/errors/TimeoutError.ts' import { CurrentUserFile } from '../types/file.ts' import { SigitFile } from './file.ts' +import { NIP05_REGEX } from '../constants.ts' export const debounceCustom = void>( fn: T, @@ -143,3 +144,25 @@ export const isPromiseRejected = ( ): result is PromiseRejectedResult => { return result.status === 'rejected' } + +/** + * Checks if it's valid {protocol}{domain} + * @param url + */ +export const isValidUrl = (url: string) => { + return /^(https?:\/\/)(?!-)([A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,6}$/gim.test(url) +} + +/** + * Checks if it's a valid domain or nip05 format + * @param value + */ +export const isValidNip05 = (value: string) => { + return NIP05_REGEX.test(value) +} + +export const isValidRelayUri = (value: string) => { + return /^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( + value + ) +}