diff --git a/docs/blossom-flow.drawio b/docs/blossom-flow.drawio
new file mode 100644
index 0000000..28b9198
--- /dev/null
+++ b/docs/blossom-flow.drawio
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/config.json b/public/config.json
new file mode 100644
index 0000000..e8e39a9
--- /dev/null
+++ b/public/config.json
@@ -0,0 +1,3 @@
+{
+ "SIGIT_BLOSSOM": "https://blossom.sigit.io"
+}
diff --git a/src/App.scss b/src/App.scss
index 29e7e8f..69e8e96 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -135,7 +135,7 @@ li {
// Consistent styling for every file mark
// Reverts some of the design defaults for font
.file-mark {
- font-family: 'Roboto';
+ font-family: 'Roboto', serif;
font-style: normal;
font-weight: normal;
letter-spacing: normal;
@@ -169,3 +169,18 @@ li {
color: rgba(0, 0, 0, 0.25);
font-size: 14px;
}
+
+.settings-container {
+ width: 100%;
+ background: white;
+ padding: 15px;
+ border-radius: 5px;
+ box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+ grid-gap: 15px;
+}
+
+.text-center {
+ text-align: center;
+}
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/controllers/AuthController.ts b/src/controllers/AuthController.ts
index 9cdf85a..1af3c0d 100644
--- a/src/controllers/AuthController.ts
+++ b/src/controllers/AuthController.ts
@@ -17,6 +17,8 @@ import {
saveAuthToken,
unixNow
} from '../utils'
+import { getFileServerMap } from '../utils/file-servers.ts'
+import { setServerMapAction } from '../store/servers/action.ts'
export class AuthController {
private nostrController: NostrController
@@ -78,16 +80,30 @@ export class AuthController {
})
)
- const relayMap = await getRelayMap(pubkey)
+ const relayMapPromise = getRelayMap(pubkey)
+ const serverMapPromise = getFileServerMap(pubkey)
+ const [relayMap, serverMap] = await Promise.all([
+ relayMapPromise,
+ serverMapPromise
+ ])
+
+ // Navigate user to relays page if relay map is empty
if (Object.keys(relayMap).length < 1) {
- // Navigate user to relays page if relay map is empty
return Promise.resolve(appPrivateRoutes.relays)
}
+ // Navigate user to servers page if server map is empty
+ if (Object.keys(serverMap).length < 1) {
+ return Promise.resolve(appPrivateRoutes.servers)
+ }
+
if (store.getState().auth.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map))
+
+ if (!compareObjects(store.getState().servers?.map, serverMap.map))
+ store.dispatch(setServerMapAction(serverMap.map))
}
/**
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/main.tsx b/src/main.tsx
index a8b1898..404c8cc 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -18,7 +18,8 @@ store.subscribe(
auth: store.getState().auth,
metadata: store.getState().metadata,
userRobotImage: store.getState().userRobotImage,
- relays: store.getState().relays
+ relays: store.getState().relays,
+ servers: store.getState().servers
})
}, 1000)
)
diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx
index bd3893e..9eb41d4 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
})
@@ -971,7 +971,7 @@ export const CreatePage = () => {
const createSignature = await generateCreateSignature(
markConfig,
fileHashes,
- ''
+ []
)
if (!createSignature) return
diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx
index 5acdd9c..0787aa3 100644
--- a/src/pages/settings/Settings.tsx
+++ b/src/pages/settings/Settings.tsx
@@ -2,12 +2,13 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle'
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
import CachedIcon from '@mui/icons-material/Cached'
import RouterIcon from '@mui/icons-material/Router'
+import StorageIcon from '@mui/icons-material/Storage'
import { ListItem, useTheme } from '@mui/material'
import List from '@mui/material/List'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import ListSubheader from '@mui/material/ListSubheader'
-import { useAppSelector } from '../../hooks/store'
+import { useAppSelector } from '../../hooks'
import { Link } from 'react-router-dom'
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
import { Container } from '../../components/Container'
@@ -74,6 +75,12 @@ export const SettingsPage = () => {
{listItem('Relays')}
+
+
+
+
+ {listItem('Servers')}
+
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
new file mode 100644
index 0000000..7746551
--- /dev/null
+++ b/src/pages/settings/servers/index.tsx
@@ -0,0 +1,261 @@
+import styles from './style.module.scss'
+import { Container } from '../../../components/Container'
+import { Footer } from '../../../components/Footer/Footer.tsx'
+import {
+ Box,
+ Button,
+ CircularProgress,
+ InputAdornment,
+ List,
+ ListItem,
+ ListItemText,
+ TextField
+} from '@mui/material'
+import StorageIcon from '@mui/icons-material/Storage'
+import DeleteIcon from '@mui/icons-material/Delete'
+import { useState } from 'react'
+import { toast } from 'react-toastify'
+import { FileServerMap, KeyboardCode } from '../../../types'
+import {
+ getFileServerMap,
+ publishFileServer
+} from '../../../utils/file-servers.ts'
+import { useAppSelector } from '../../../hooks'
+import { useDidMount } from '../../../hooks'
+import { isValidUrl, MAXIMUM_BLOSSOMS_LENGTH } from '../../../utils'
+import axios from 'axios'
+import { cloneDeep } from 'lodash'
+
+const protocol = 'https://'
+
+const errors = {
+ urlNotValid:
+ 'New server URL is not valid. Example of valid server URL: blossom.sigit.io'
+}
+
+export const ServersPage = () => {
+ const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
+
+ const [newServerURL, setNewServerURL] = useState('')
+ const [newRelayURLerror, setNewRelayURLerror] = useState()
+ const [loadingServers, setLoadingServers] = useState(true)
+
+ const [blossomServersMap, setBlossomServersMap] = useState({})
+
+ useDidMount(() => {
+ fetchFileServers()
+ })
+
+ const fetchFileServers = async () => {
+ if (usersPubkey) {
+ await getFileServerMap(usersPubkey).then((res) => {
+ if (res.map) {
+ if (Object.keys(res.map).length === 0) {
+ serverRequirementWarning()
+ }
+
+ setBlossomServersMap(res.map)
+ }
+ })
+ } else {
+ noUserKeyWarning()
+ }
+
+ setLoadingServers(false)
+ }
+
+ const noUserKeyWarning = () => toast.warning('No user key available.')
+
+ const serverRequirementWarning = () =>
+ toast.warning('At least one Blossom server is needed for SIGit to work.')
+
+ const handleAddNewServer = async () => {
+ if (!newServerURL.length) {
+ setNewRelayURLerror(errors.urlNotValid)
+ return
+ }
+
+ if (Object.keys(blossomServersMap).length === MAXIMUM_BLOSSOMS_LENGTH) {
+ return toast.warning(
+ `You can only add a maximum of ${MAXIMUM_BLOSSOMS_LENGTH} blossom servers.`
+ )
+ }
+
+ const serverURL = `${protocol}${newServerURL?.trim().replace(protocol, '')}`
+ if (!serverURL) return
+
+ // Check if new server is a valid URL
+ if (!isValidUrl(serverURL)) {
+ if (serverURL !== protocol) {
+ setNewRelayURLerror(errors.urlNotValid)
+ return
+ }
+ }
+
+ if (Object.keys(blossomServersMap).includes(serverURL))
+ return toast.warning('This server is already added.')
+
+ const valid = await validateFileServer(serverURL).catch(() => null)
+ if (!valid)
+ return toast.warning(
+ `Server URL ${serverURL} does not seem to be a valid file server.`
+ )
+
+ setNewRelayURLerror('')
+ const tempBlossomServersMap = blossomServersMap
+ tempBlossomServersMap[serverURL] = { write: true, read: true }
+ setBlossomServersMap(tempBlossomServersMap)
+ setNewServerURL('')
+
+ publishFileServersList(tempBlossomServersMap)
+ }
+
+ const handleDeleteServer = (serverURL: string) => {
+ if (Object.keys(blossomServersMap).length === 1)
+ return serverRequirementWarning()
+
+ // Remove server from the list
+ const tempBlossomServersMap = cloneDeep(blossomServersMap)
+ delete tempBlossomServersMap[serverURL]
+
+ setBlossomServersMap(tempBlossomServersMap)
+ // Publish new list to the relays
+ publishFileServersList(tempBlossomServersMap)
+ }
+
+ const publishFileServersList = (fileServersMap: FileServerMap) => {
+ if (!usersPubkey)
+ return toast.warning(
+ 'No user key available, please reload and try again.'
+ )
+
+ publishFileServer(fileServersMap, usersPubkey)
+ .then((res) => {
+ toast.success(res)
+ })
+ .catch((err) => {
+ toast.error(err)
+ })
+ }
+
+ const handleInputKeydown = (event: React.KeyboardEvent) => {
+ if (
+ event.code === KeyboardCode.Enter ||
+ event.code === KeyboardCode.NumpadEnter
+ ) {
+ event.preventDefault()
+ handleAddNewServer()
+ }
+ }
+
+ /**
+ * Checks if the file server is up and valid
+ * For now check will just include sending a GET request and checking if
+ * returned HTML includes word `Blossom`.
+ *
+ * Probably later, there will be appropriate sepc universal to all file servers
+ * which would include some kind of "check" endpoint.
+ * @param serverURL
+ */
+ const validateFileServer = (serverURL: string) => {
+ return new Promise((resolve, reject) => {
+ axios
+ .get(serverURL)
+ .then((res) => {
+ if (res && res.data?.toLowerCase().includes('blossom server')) {
+ resolve(true)
+ } else {
+ reject(false)
+ }
+ })
+ .catch((err) => {
+ reject(err)
+ })
+ })
+ }
+
+ return (
+
+
+ setNewServerURL(e.target.value)}
+ helperText={newRelayURLerror}
+ error={!!newRelayURLerror}
+ InputProps={{
+ startAdornment: (
+ {protocol}
+ )
+ }}
+ className={styles.serverURItextfield}
+ />
+
+
+
+
+ YOUR BLOSSOM SERVERS
+
+
+ {loadingServers && (
+
+
+
+ )}
+
+ {blossomServersMap && (
+
+ {Object.keys(blossomServersMap).map((key) => (
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+interface ServerItemProps {
+ serverURL: string
+ preventDelete?: boolean
+ handleDeleteServer?: (serverURL: string) => void
+}
+
+const ServerItem = ({
+ serverURL,
+ handleDeleteServer,
+ preventDelete
+}: ServerItemProps) => {
+ return (
+
+
+
+
+
+
+
+ handleDeleteServer && handleDeleteServer(serverURL)}
+ className={`${styles.leaveServerContainer} ${preventDelete ? styles.disabled : ''}`}
+ >
+
+ Remove
+
+
+
+
+ )
+}
diff --git a/src/pages/settings/servers/style.module.scss b/src/pages/settings/servers/style.module.scss
new file mode 100644
index 0000000..79a1269
--- /dev/null
+++ b/src/pages/settings/servers/style.module.scss
@@ -0,0 +1,111 @@
+@import '../../../styles/colors.scss';
+
+.container {
+ color: $text-color;
+
+ .serverURItextfield {
+ width: 100%;
+ }
+
+ .serverAddContainer {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ width: 100%;
+ }
+
+ .sectionIcon {
+ font-size: 30px;
+ }
+
+ .sectionTitle {
+ margin-top: 35px;
+ margin-bottom: 10px;
+ display: flex;
+ flex-direction: row;
+ gap: 5px;
+ font-size: 1.5rem;
+ line-height: 2rem;
+ font-weight: 600;
+ }
+
+ .serversContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .server {
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ border-radius: 4px;
+
+ .relayDivider {
+ margin-left: 10px;
+ margin-right: 10px;
+ }
+
+ .leaveServerContainer {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ cursor: pointer;
+
+ &.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+ }
+
+ .showInfo {
+ cursor: pointer;
+ }
+
+ .showInfoIcon {
+ margin-right: 3px;
+ margin-bottom: auto;
+ vertical-align: middle;
+ }
+
+ .relayInfoContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ text-wrap: wrap;
+ }
+
+ .relayInfoTitle {
+ font-weight: 600;
+ }
+
+ .relayInfoSubTitle {
+ font-weight: 500;
+ }
+
+ .copyItem {
+ margin-left: 10px;
+ color: #34495e;
+ vertical-align: bottom;
+ cursor: pointer;
+ }
+
+ .connectionStatus {
+ border-radius: 9999px;
+ width: 10px;
+ height: 10px;
+ margin-right: 5px;
+ margin-top: 2px;
+ }
+
+ .connectionStatusConnected {
+ background-color: $relay-status-connected;
+ }
+
+ .connectionStatusNotConnected {
+ background-color: $relay-status-notconnected;
+ }
+
+ .connectionStatusUnknown {
+ background-color: $input-text-color;
+ }
+ }
+}
diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx
index fe9d047..f9427cd 100644
--- a/src/pages/sign/index.tsx
+++ b/src/pages/sign/index.tsx
@@ -167,6 +167,7 @@ export const SignPage = () => {
createSignatureContent.markConfig,
usersPubkey!
)
+ // TODO figure out why markConfig does not contain the usersPubkey when multiple signer
const signedMarks = extractMarksFromSignedMeta(meta)
const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks)
const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!)
@@ -287,26 +288,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 +488,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 +673,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 +717,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..0da3171 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,56 @@ 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 = zipUrls[i]
+ 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) => {
+ const error = err.message
+ ? `Decryption error: ${err.message}`
+ : 'An error occurred in decrypting file.'
+ console.error('Error in decryption:>> ', err)
+ toast.error(error)
+ 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 +460,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/routes/index.tsx b/src/routes/index.tsx
index f3580f9..fc37573 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -8,6 +8,7 @@ export const appPrivateRoutes = {
profileSettings: '/settings/profile/:npub',
cacheSettings: '/settings/cache',
relays: '/settings/relays',
+ servers: '/settings/servers',
nostrLogin: '/settings/nostrLogin'
}
diff --git a/src/routes/util.tsx b/src/routes/util.tsx
index 8773b81..0f64d92 100644
--- a/src/routes/util.tsx
+++ b/src/routes/util.tsx
@@ -11,6 +11,7 @@ import { RelaysPage } from '../pages/settings/relays'
import { SettingsPage } from '../pages/settings/Settings'
import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify'
+import { ServersPage } from '../pages/settings/servers'
/**
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
@@ -96,6 +97,10 @@ export const privateRoutes = [
path: appPrivateRoutes.relays,
element:
},
+ {
+ path: appPrivateRoutes.servers,
+ element:
+ },
{
path: appPrivateRoutes.nostrLogin,
element:
diff --git a/src/services/config/index.ts b/src/services/config/index.ts
new file mode 100644
index 0000000..624aba6
--- /dev/null
+++ b/src/services/config/index.ts
@@ -0,0 +1,54 @@
+import axios from 'axios'
+import { ILocalConfig } from '../../types/config.ts'
+
+class LocalConfig {
+ // Static property to hold the single instance of LocalCache
+ private static instance: LocalConfig | null = null
+
+ private config: ILocalConfig
+
+ // Private constructor to prevent direct instantiation
+ private constructor() {
+ // Set default config
+ this.config = {
+ SIGIT_BLOSSOM: 'https://blossom.sigit.io'
+ }
+ }
+
+ // Method to initialize the database
+ private async init() {
+ axios
+ .get('/config.json')
+ .then((response) => {
+ console.log('response', response)
+
+ if (typeof response.data === 'object') {
+ this.config = response.data
+ } else {
+ throw 'Failed to load config.json: File not found'
+ }
+ })
+ .catch((error) => {
+ console.error('Failed to load config.json:', error)
+ console.warn('Default config will be used.')
+ })
+ }
+
+ // Static method to get the single instance of LocalCache
+ public static getInstance(): LocalConfig {
+ // If the instance doesn't exist, create it
+ if (!LocalConfig.instance) {
+ LocalConfig.instance = new LocalConfig()
+ LocalConfig.instance.init()
+ }
+ // Return the single instance of LocalCache
+ return LocalConfig.instance
+ }
+
+ public getConfig() {
+ return this.config
+ }
+}
+
+// Export the single instance of LocalCache
+export const localConfig = LocalConfig.getInstance()
diff --git a/src/store/actionTypes.ts b/src/store/actionTypes.ts
index 90fa99b..bc77195 100644
--- a/src/store/actionTypes.ts
+++ b/src/store/actionTypes.ts
@@ -11,6 +11,9 @@ export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE'
+export const SET_SERVER_MAP = 'SET_SERVER_MAP'
+export const SET_SERVER_MAP_UPDATED = 'SET_SERVER_MAP_UPDATED'
+
export const SET_RELAY_MAP = 'SET_RELAY_MAP'
export const SET_RELAY_INFO = 'SET_RELAY_INFO'
export const SET_RELAY_MAP_UPDATED = 'SET_RELAY_MAP_UPDATED'
diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts
index 61b2837..895e333 100644
--- a/src/store/rootReducer.ts
+++ b/src/store/rootReducer.ts
@@ -9,15 +9,18 @@ import relaysReducer from './relays/reducer'
import { RelaysDispatchTypes, RelaysState } from './relays/types'
import UserAppDataReducer from './userAppData/reducer'
import userRobotImageReducer from './userRobotImage/reducer'
+import serversReducer from './servers/reducer'
import { MetadataDispatchTypes } from './metadata/types'
import { UserAppDataDispatchTypes } from './userAppData/types'
import { UserRobotImageDispatchTypes } from './userRobotImage/types'
+import { ServersDispatchTypes, ServersState } from './servers/types.ts'
export interface State {
auth: AuthState
metadata?: Event
userRobotImage?: string
relays: RelaysState
+ servers: ServersState
userAppData?: UserAppData
}
@@ -26,6 +29,7 @@ type AppActions =
| MetadataDispatchTypes
| UserRobotImageDispatchTypes
| RelaysDispatchTypes
+ | ServersDispatchTypes
| UserAppDataDispatchTypes
export const appReducer = combineReducers({
@@ -33,6 +37,7 @@ export const appReducer = combineReducers({
metadata: metadataReducer,
userRobotImage: userRobotImageReducer,
relays: relaysReducer,
+ servers: serversReducer,
userAppData: UserAppDataReducer
})
diff --git a/src/store/servers/action.ts b/src/store/servers/action.ts
new file mode 100644
index 0000000..9ef9e33
--- /dev/null
+++ b/src/store/servers/action.ts
@@ -0,0 +1,12 @@
+import * as ActionTypes from '../actionTypes'
+import { SetServerMapAction, SetServerMapUpdatedAction } from './types'
+import { ServerMap } from '../../types'
+
+export const setServerMapAction = (payload: ServerMap): SetServerMapAction => ({
+ type: ActionTypes.SET_SERVER_MAP,
+ payload
+})
+
+export const setServerMapUpdatedAction = (): SetServerMapUpdatedAction => ({
+ type: ActionTypes.SET_SERVER_MAP_UPDATED
+})
diff --git a/src/store/servers/reducer.ts b/src/store/servers/reducer.ts
new file mode 100644
index 0000000..1f06a44
--- /dev/null
+++ b/src/store/servers/reducer.ts
@@ -0,0 +1,28 @@
+import * as ActionTypes from '../actionTypes'
+import { ServersDispatchTypes, ServersState } from './types'
+
+const initialState: ServersState = {
+ map: undefined,
+ mapUpdated: undefined
+}
+
+const reducer = (
+ state = initialState,
+ action: ServersDispatchTypes
+): ServersState => {
+ switch (action.type) {
+ case ActionTypes.SET_SERVER_MAP:
+ return { ...state, map: action.payload, mapUpdated: Date.now() }
+
+ case ActionTypes.SET_SERVER_MAP_UPDATED:
+ return { ...state, mapUpdated: Date.now() }
+
+ case ActionTypes.RESTORE_STATE:
+ return action.payload.servers || initialState
+
+ default:
+ return state
+ }
+}
+
+export default reducer
diff --git a/src/store/servers/types.ts b/src/store/servers/types.ts
new file mode 100644
index 0000000..41bda0a
--- /dev/null
+++ b/src/store/servers/types.ts
@@ -0,0 +1,22 @@
+import * as ActionTypes from '../actionTypes'
+import { RestoreState } from '../actions'
+import { ServerMap } from '../../types'
+
+export type ServersState = {
+ map?: ServerMap
+ mapUpdated?: number
+}
+
+export interface SetServerMapAction {
+ type: typeof ActionTypes.SET_SERVER_MAP
+ payload: ServerMap
+}
+
+export interface SetServerMapUpdatedAction {
+ type: typeof ActionTypes.SET_SERVER_MAP_UPDATED
+}
+
+export type ServersDispatchTypes =
+ | SetServerMapAction
+ | SetServerMapUpdatedAction
+ | RestoreState
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/config.ts b/src/types/config.ts
new file mode 100644
index 0000000..fc76a08
--- /dev/null
+++ b/src/types/config.ts
@@ -0,0 +1,3 @@
+export interface ILocalConfig {
+ SIGIT_BLOSSOM: string
+}
diff --git a/src/types/core.ts b/src/types/core.ts
index f07dbf7..1c2a49b 100644
--- a/src/types/core.ts
+++ b/src/types/core.ts
@@ -27,7 +27,7 @@ export interface CreateSignatureEventContent {
fileHashes: { [key: string]: string }
markConfig: Mark[]
title: string
- zipUrl: string
+ zipUrls: string[]
}
export interface SignedEventContent {
@@ -75,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 {
@@ -85,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/types/file-server.ts b/src/types/file-server.ts
new file mode 100644
index 0000000..3522ddf
--- /dev/null
+++ b/src/types/file-server.ts
@@ -0,0 +1,14 @@
+export type FileServerMap = {
+ [key: string]: {
+ read: boolean
+ write: boolean
+ }
+}
+
+export interface FileServerPutResponse {
+ sha256: string
+ size: number
+ uploaded: number
+ type: string
+ url: string
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index fd242b2..e0203ed 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -5,3 +5,5 @@ export * from './profile'
export * from './relay'
export * from './zip'
export * from './event'
+export * from './server'
+export * from './file-server.ts'
diff --git a/src/types/server.ts b/src/types/server.ts
new file mode 100644
index 0000000..db386bc
--- /dev/null
+++ b/src/types/server.ts
@@ -0,0 +1,6 @@
+export type ServerMap = {
+ [key: string]: {
+ read: boolean
+ write: boolean
+ }
+}
diff --git a/src/utils/const.ts b/src/utils/const.ts
index 2b8e822..3111ad5 100644
--- a/src/utils/const.ts
+++ b/src/utils/const.ts
@@ -1,7 +1,12 @@
+import { localConfig } from '../services/config'
+import { ILocalConfig } from '../types/config.ts'
+
export const EMPTY: string = ''
export const ARRAY_BUFFER = 'arraybuffer'
export const DEFLATE = 'DEFLATE'
+const config: ILocalConfig = localConfig.getConfig()
+
/**
* Number of milliseconds in one week.
*/
@@ -14,7 +19,7 @@ export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000
export const SIGIT_RELAY = 'wss://relay.sigit.io'
-export const SIGIT_BLOSSOM = 'https://blossom.sigit.io'
+export const SIGIT_BLOSSOM = config.SIGIT_BLOSSOM
export const DEFAULT_LOOK_UP_RELAY_LIST = [
SIGIT_RELAY,
@@ -22,6 +27,8 @@ export const DEFAULT_LOOK_UP_RELAY_LIST = [
'wss://purplepag.es'
]
+export const MAXIMUM_BLOSSOMS_LENGTH = 3
+
// Uses the https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types list
// Updated on 2024/08/22
export const MOST_COMMON_MEDIA_TYPES = new Map([
diff --git a/src/utils/file-servers.ts b/src/utils/file-servers.ts
new file mode 100644
index 0000000..387db50
--- /dev/null
+++ b/src/utils/file-servers.ts
@@ -0,0 +1,115 @@
+import { FileServerMap } from '../types'
+import { NostrController, relayController } from '../controllers'
+import { DEFAULT_LOOK_UP_RELAY_LIST, SIGIT_BLOSSOM } from './const.ts'
+import { unixNow } from './nostr.ts'
+import { Filter, UnsignedEvent, kinds } from 'nostr-tools'
+
+/**
+ * Fetches the relays to get preferred file servers for the given npub
+ * @param npub hex pubkey
+ */
+const getFileServerMap = async (
+ npub: string
+): Promise<{ map: FileServerMap; mapUpdated?: number }> => {
+ // More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/96.md
+ const eventFilter: Filter = {
+ kinds: [kinds.FileServerPreference],
+ authors: [npub]
+ }
+
+ const event = await relayController
+ .fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST)
+ .catch((err) => {
+ return Promise.reject(err)
+ })
+
+ if (event) {
+ // Handle found event 10096
+ const fileServersMap: FileServerMap = {}
+
+ const serverTags = event.tags.filter((tag) => tag[0] === 'server')
+
+ serverTags.forEach((tag) => {
+ const url = tag[1]
+ const serverType = tag[2]
+
+ // if 3rd element of server tag is undefined, server is WRITE and READ
+ fileServersMap[url] = {
+ write: serverType ? serverType === 'write' : true,
+ read: serverType ? serverType === 'read' : true
+ }
+ })
+
+ return Promise.resolve({
+ map: fileServersMap,
+ mapUpdated: event.created_at
+ })
+ } else {
+ return Promise.resolve({ map: getDefaultFileServerMap() })
+ }
+}
+
+/**
+ * Publishes a preferred file servers list for the given npub
+ * @param serverMap list of preferred servers
+ * @param npub hex pubkey
+ * @param extraRelaysToPublish additional relay on which to publish
+ */
+const publishFileServer = async (
+ serverMap: FileServerMap,
+ npub: string,
+ extraRelaysToPublish?: string[]
+): Promise => {
+ const timestamp = unixNow()
+ const serverURLs = Object.keys(serverMap)
+
+ // More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
+ const tags: string[][] = serverURLs.map((serverURL) => {
+ const serverTag = ['server', serverURL]
+
+ return serverTag.filter((value) => value !== '')
+ })
+
+ const newRelayMapEvent: UnsignedEvent = {
+ kind: kinds.FileServerPreference,
+ tags,
+ content: '',
+ pubkey: npub,
+ created_at: timestamp
+ }
+
+ const nostrController = NostrController.getInstance()
+ const signedEvent = await nostrController.signEvent(newRelayMapEvent)
+
+ let relaysToPublish = serverURLs
+
+ // Add extra relays if provided
+ if (extraRelaysToPublish) {
+ relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
+ }
+
+ // If a relay map is empty, use the most popular relay URIs
+ if (!relaysToPublish.length) {
+ relaysToPublish = DEFAULT_LOOK_UP_RELAY_LIST
+ }
+ const publishResult = await relayController.publish(
+ signedEvent,
+ relaysToPublish
+ )
+
+ if (publishResult && publishResult.length) {
+ return Promise.resolve(
+ `Preferred file servers published on: ${publishResult.join('\n')}`
+ )
+ }
+
+ return Promise.reject(
+ 'Publishing updated preferred file servers was unsuccessful.'
+ )
+}
+
+const getDefaultFileServerMap = (): FileServerMap => ({
+ [SIGIT_BLOSSOM]: { write: true, read: true }
+})
+
+export { getFileServerMap, publishFileServer }
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 474e8dc..de49b65 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,
@@ -441,16 +443,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
@@ -473,6 +475,7 @@ export const getUsersAppData = async (): Promise => {
// Parse the decrypted content
const parsedContent = await parseJson<{
+ blossomVersions: BlossomVersion[]
blossomUrls: string[]
keyPair: Keys
}>(decrypted).catch((err) => {
@@ -488,14 +491,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
)
@@ -506,7 +518,7 @@ export const getUsersAppData = async (): Promise => {
// Return the final user application data
return {
- blossomUrls,
+ blossomVersions: blossomVersions,
keyPair,
sigits,
processedGiftWraps
@@ -550,9 +562,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
@@ -567,21 +579,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
+ )
+ })
+ }
})
}
@@ -593,7 +610,7 @@ export const updateUsersAppData = async (meta: Meta) => {
.nip04Encrypt(
usersPubkey,
JSON.stringify({
- blossomUrls,
+ blossomVersions: blossomVersions,
keyPair: appData.keyPair
})
)
@@ -663,7 +680,7 @@ export const updateUsersAppData = async (meta: Meta) => {
store.dispatch(
updateUserAppDataAction({
sigits,
- blossomUrls,
+ blossomVersions: blossomVersions,
processedGiftWraps: [...appData.processedGiftWraps],
keyPair: {
...appData.keyPair
@@ -703,7 +720,7 @@ const deleteBlossomFile = async (url: string, privateKey: string) => {
}
/**
- * Function to upload user application data to the 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.
@@ -714,6 +731,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,
@@ -760,30 +782,48 @@ const uploadUserAppDataToBlossom = async (
// Finalize the event with the private key
const authEvent = finalizeEvent(event, hexToBytes(privateKey))
- // 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
- }
- })
+ 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
+ }
+ }
+ )
+
+ 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) => {
@@ -795,8 +835,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) {
@@ -957,7 +1002,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
+ )
+}