diff --git a/docs/blossom-flow.drawio b/docs/blossom-flow.drawio
new file mode 100644
index 0000000..1cfee04
--- /dev/null
+++ b/docs/blossom-flow.drawio
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.scss b/src/App.scss
index 29e7e8f..462b284 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -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/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx
index 5acdd9c..3cfc592 100644
--- a/src/pages/settings/Settings.tsx
+++ b/src/pages/settings/Settings.tsx
@@ -2,6 +2,7 @@ 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'
@@ -74,6 +75,12 @@ export const SettingsPage = () => {
{listItem('Relays')}
+
+
+
+
+ {listItem('Servers')}
+
diff --git a/src/pages/settings/servers/index.tsx b/src/pages/settings/servers/index.tsx
new file mode 100644
index 0000000..a5fb842
--- /dev/null
+++ b/src/pages/settings/servers/index.tsx
@@ -0,0 +1,256 @@
+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 {
+ getFileServers,
+ publishFileServer
+} from '../../../utils/file-servers.ts'
+import { useAppSelector } from '../../../hooks/store.ts'
+import { useDidMount } from '../../../hooks'
+import { SIGIT_BLOSSOM } 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 getFileServers(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
+ }
+
+ const serverURL = `${protocol}${newServerURL?.trim().replace(protocol, '')}`
+ 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 (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 (
+ serverURL === SIGIT_BLOSSOM &&
+ 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
+ handleDeleteServer?: (serverURL: string) => void
+}
+
+const ServerItem = ({ serverURL, handleDeleteServer }: ServerItemProps) => {
+ return (
+
+
+
+
+
+
+
+ handleDeleteServer && handleDeleteServer(serverURL)}
+ className={`${styles.leaveServerContainer} ${serverURL === SIGIT_BLOSSOM ? 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/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/types/core.ts b/src/types/core.ts
index df55a07..38cfb94 100644
--- a/src/types/core.ts
+++ b/src/types/core.ts
@@ -19,6 +19,7 @@ export interface Meta {
exportSignature?: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
timestamps?: OpenTimestamp[]
+ // TODO Add field: fileServers
}
export interface CreateSignatureEventContent {
diff --git a/src/types/file-server.ts b/src/types/file-server.ts
new file mode 100644
index 0000000..9a1d608
--- /dev/null
+++ b/src/types/file-server.ts
@@ -0,0 +1,6 @@
+export type FileServerMap = {
+ [key: string]: {
+ read: boolean
+ write: boolean
+ }
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index fd242b2..342c880 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -5,3 +5,4 @@ export * from './profile'
export * from './relay'
export * from './zip'
export * from './event'
+export * from './file-server.ts'
diff --git a/src/utils/file-servers.ts b/src/utils/file-servers.ts
new file mode 100644
index 0000000..900c6f1
--- /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 getFileServers = 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 { getFileServers, publishFileServer }
diff --git a/src/utils/nostr.ts b/src/utils/nostr.ts
index ec8c97e..ac7ff72 100644
--- a/src/utils/nostr.ts
+++ b/src/utils/nostr.ts
@@ -688,7 +688,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 SIGit Blossom server.
* @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.
@@ -744,7 +744,7 @@ 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: {