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) => ( + + ))} + + )} +