issue-274 #278

Open
m wants to merge 14 commits from issue-274 into staging
35 changed files with 1230 additions and 193 deletions

181
docs/blossom-flow.drawio Normal file
View File

@ -0,0 +1,181 @@
<mxfile host="drawio-plugin" modified="2024-12-24T14:10:11.548Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" etag="j0ni0qfsydyzknoEy44Z" version="22.1.22" type="embed">
<diagram id="ADjf0_COJFV7FXKRQUJh" name="Page-1">
<mxGraphModel dx="1130" dy="824" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" background="#ffffff" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="13" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="11" target="12" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="11" value="User login" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="30" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="15" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="12" target="14" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="12" value="Find all blossom servers provided by a user" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="150" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="14" value="Fetch all SIGITS from each blossom server and display them all" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="280" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="22" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="17" target="21" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="17" value="User opens a SIGIT" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="30" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="24" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="21" target="23" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="21" value="Display a blossom server where this SIGIT was found" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="345" y="150" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="23" value="Display other blossom servers where SIGIT is not found, and button which will publish to a blossom server" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="345" y="250" width="120" height="100" as="geometry" />
</mxCell>
<mxCell id="26" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;rounded=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="270" y="380" as="sourcePoint" />
<mxPoint x="270" y="30" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="27" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;rounded=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="580" y="380" as="sourcePoint" />
<mxPoint x="580" y="30" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="30" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="28" target="29" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="28" value="Settings page (settings/servers)" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="640" y="30" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="32" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="29" target="31" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="29" value="Allow user to add and remove Blossom servers" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="665" y="150" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="31" value="Show suggested Blossom servers" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="665" y="250" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="33" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;rounded=0;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="40" y="426" as="sourcePoint" />
<mxPoint x="820" y="426" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="39" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="34" target="38" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="34" value="User 1" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="620" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="35" value="&lt;span style=&quot;color: rgb(0, 0, 0); font-family: Helvetica; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;&quot;&gt;Servers: A, B, C&lt;/span&gt;" style="text;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="70" y="570" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="42" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="36" target="41" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="36" value="User 2" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="620" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="37" value="&lt;span style=&quot;color: rgb(0, 0, 0); font-family: Helvetica; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;&quot;&gt;Servers: D, E&lt;/span&gt;" style="text;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="350" y="570" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="38" target="36" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="38" value="Creates document, sign and publish to A, B, C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="770" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="44" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="41" target="43" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="41" value="Reads the files From A, B, C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="770" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="43" value="Sign, update the meta.json and publish to D, E" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="920" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="45" value="&lt;span style=&quot;color: rgb(0, 0, 0); font-family: Helvetica; font-size: 18px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;&quot;&gt;File Servers (Blossom)&lt;/span&gt;" style="text;whiteSpace=wrap;html=1;fontSize=18;" parent="1" vertex="1">
<mxGeometry x="330" y="440" width="190" height="30" as="geometry" />
</mxCell>
<mxCell id="46" value="&lt;span style=&quot;color: rgb(0, 0, 0); font-family: Helvetica; font-size: 18px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;&quot;&gt;Sigits&lt;/span&gt;" style="text;whiteSpace=wrap;html=1;fontSize=18;" parent="1" vertex="1">
<mxGeometry x="40" y="510" width="50" height="30" as="geometry" />
</mxCell>
<mxCell id="47" value="&lt;span style=&quot;color: rgb(0, 0, 0); font-family: Helvetica; font-size: 18px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;&quot;&gt;User App Data&lt;/span&gt;" style="text;whiteSpace=wrap;html=1;fontSize=18;" parent="1" vertex="1">
<mxGeometry x="40" y="1050" width="120" height="30" as="geometry" />
</mxCell>
<mxCell id="52" value="Loads the page" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="49" target="51" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="49" value="User" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="1290" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="50" value="&lt;span style=&quot;color: rgb(0, 0, 0); font-family: Helvetica; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: center; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(251, 251, 251); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial; float: none; display: inline !important;&quot;&gt;Servers: A, B, C&lt;/span&gt;" style="text;whiteSpace=wrap;html=1;fontSize=15;" parent="1" vertex="1">
<mxGeometry x="70" y="1240" width="110" height="30" as="geometry" />
</mxCell>
<mxCell id="61" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="51" target="60" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="51" value="Fetche the app data from A, B, C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="1450" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="64" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="58" target="63" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="58" value="List the sigits found in the app data" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="1750" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="62" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="60" target="58" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="60" value="We have 3 data sources, we merge all 3 sets into 1 object" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="40" y="1600" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="63" value="User opens a SIGIT (flow is on the top)" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="65" y="1920" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="65" value="&lt;p style=&quot;margin:0px;margin-top:4px;text-align:center;text-decoration:underline;&quot;&gt;&lt;b&gt;interface UserAppData&lt;/b&gt;&lt;/p&gt;&lt;hr&gt;&lt;p style=&quot;margin: 0px 0px 0px 8px; font-size: 14px;&quot;&gt;sigits&lt;br style=&quot;border-color: var(--border-color); text-align: center;&quot;&gt;&lt;span style=&quot;text-align: center;&quot;&gt;blossomVersions&lt;/span&gt;&lt;br style=&quot;border-color: var(--border-color); text-align: center;&quot;&gt;&lt;span style=&quot;text-align: center;&quot;&gt;processedGiftWrapps&lt;/span&gt;&lt;br style=&quot;border-color: var(--border-color); text-align: center;&quot;&gt;&lt;span style=&quot;text-align: center;&quot;&gt;keyPair&lt;/span&gt;&lt;br&gt;&lt;/p&gt;" style="verticalAlign=top;align=left;overflow=fill;fontSize=12;fontFamily=Helvetica;html=1;whiteSpace=wrap;" parent="1" vertex="1">
<mxGeometry x="40" y="1100" width="160" height="110" as="geometry" />
</mxCell>
<mxCell id="68" value="Creates a SIGIT" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="66" target="67" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="66" value="User" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="1290" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="70" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="67" target="69" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="67" value="Publish the SIGIT to A,B,C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="1450" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="72" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="1" source="69" target="71" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="69" value="Capture the SIGIT urls and publish them in UserAppData to the servers A,B,C" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="1610" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="74" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="71" target="73">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="71" value="Keep last&amp;nbsp; 10 versions of blossom which includes SIGIT" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="320" y="1770" width="170" height="80" as="geometry" />
</mxCell>
<mxCell id="79" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" edge="1" parent="1" source="73" target="77">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="73" value="Every blossom version can have multiple links that user added" style="whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="345" y="1920" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="77" value="Get latest version, if it has multiple URLs choose the first one which matches the hash" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="320" y="2060" width="170" height="80" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

3
public/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"SIGIT_BLOSSOM": "https://blossom.sigit.io"
}

View File

@ -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;
}

View File

@ -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

View File

@ -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))
}
/**

View File

@ -86,7 +86,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
}>({})
const [markConfig, setMarkConfig] = useState<Mark[]>([])
const [title, setTitle] = useState<string>('')
const [zipUrl, setZipUrl] = useState<string>('')
const [zipUrls, setZipUrls] = useState<string[]>([])
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,

View File

@ -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)
)

View File

@ -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<string | null> => {
): Promise<string[] | null> => {
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

View File

@ -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 = () => {
</ListItemIcon>
{listItem('Relays')}
</ListItem>
<ListItem component={Link} to={appPrivateRoutes.servers}>
<ListItemIcon>
<StorageIcon />
</ListItemIcon>
{listItem('Servers')}
</ListItem>
<ListItem component={Link} to={appPrivateRoutes.cacheSettings}>
<ListItemIcon>
<CachedIcon />

View File

@ -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'

View File

@ -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<string>('')
const [newRelayURLerror, setNewRelayURLerror] = useState<string>()
const [loadingServers, setLoadingServers] = useState<boolean>(true)
const [blossomServersMap, setBlossomServersMap] = useState<FileServerMap>({})
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.`
)
}
m marked this conversation as resolved Outdated
Outdated
Review

should be a utility

should be a utility
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<HTMLDivElement>) => {
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 (
<Container className={`settings-container ${styles.container}`}>
<Box className={styles.serverAddContainer}>
<TextField
label="Add new blossom server"
value={newServerURL}
onKeyDown={handleInputKeydown}
onChange={(e) => setNewServerURL(e.target.value)}
helperText={newRelayURLerror}
error={!!newRelayURLerror}
InputProps={{
startAdornment: (
<InputAdornment position="start">{protocol}</InputAdornment>
)
}}
className={styles.serverURItextfield}
/>
<Button variant="contained" onClick={() => handleAddNewServer()}>
Add
</Button>
</Box>
<Box className={styles.sectionTitle}>
<StorageIcon className={styles.sectionIcon} />
<span>YOUR BLOSSOM SERVERS</span>
</Box>
{loadingServers && (
<div className="text-center">
<CircularProgress />
</div>
)}
{blossomServersMap && (
<Box className={styles.serversContainer}>
{Object.keys(blossomServersMap).map((key) => (
<ServerItem
key={key}
serverURL={key}
preventDelete={Object.keys(blossomServersMap).length === 1}
handleDeleteServer={handleDeleteServer}
/>
))}
</Box>
)}
<Footer />
</Container>
)
}
interface ServerItemProps {
serverURL: string
preventDelete?: boolean
handleDeleteServer?: (serverURL: string) => void
}
const ServerItem = ({
serverURL,
handleDeleteServer,
preventDelete
}: ServerItemProps) => {
return (
<Box className={styles.server}>
<List>
<ListItem>
<span
className={[
styles.connectionStatus,
styles.connectionStatusConnected
].join(' ')}
/>
<ListItemText primary={serverURL} />
<Box
onClick={() => handleDeleteServer && handleDeleteServer(serverURL)}
className={`${styles.leaveServerContainer} ${preventDelete ? styles.disabled : ''}`}
>
<DeleteIcon />
<span>Remove</span>
</Box>
</ListItem>
</List>
</Box>
)
}

View File

@ -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;
}
}
}

View File

@ -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(() => {

View File

@ -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

View File

@ -8,6 +8,7 @@ export const appPrivateRoutes = {
profileSettings: '/settings/profile/:npub',
cacheSettings: '/settings/cache',
relays: '/settings/relays',
servers: '/settings/servers',
nostrLogin: '/settings/nostrLogin'
}

View File

@ -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: <RelaysPage />
},
{
path: appPrivateRoutes.servers,
element: <ServersPage />
},
{
path: appPrivateRoutes.nostrLogin,
element: <NostrLoginPage />

View File

@ -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<ILocalConfig>('/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()

View File

@ -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'

View File

@ -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
})

View File

@ -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
})

View File

@ -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

View File

@ -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

View File

@ -5,7 +5,7 @@ import { UserAppDataDispatchTypes } from './types'
const initialState: UserAppData = {
sigits: {},
processedGiftWraps: [],
blossomUrls: []
blossomVersions: []
}
const reducer = (

3
src/types/config.ts Normal file
View File

@ -0,0 +1,3 @@
export interface ILocalConfig {
SIGIT_BLOSSOM: string
}

View File

@ -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'
}

View File

@ -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 {

14
src/types/file-server.ts Normal file
View File

@ -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
}

View File

@ -5,3 +5,5 @@ export * from './profile'
export * from './relay'
export * from './zip'
export * from './event'
export * from './server'
export * from './file-server.ts'

6
src/types/server.ts Normal file
View File

@ -0,0 +1,6 @@
export type ServerMap = {
[key: string]: {
read: boolean
write: boolean
}
}

View File

@ -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([

115
src/utils/file-servers.ts Normal file
View File

@ -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<string> => {
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 }

View File

@ -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<Meta> => {
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<Meta>(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<Meta>(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
)
}

View File

@ -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<string[]> => {
// 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<AxiosResponse<FileServerPutResponse>>[] = []
// 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<FileServerPutResponse>(
`${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
}
}

View File

@ -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<UserAppData | null> => {
// 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<UserAppData | null> => {
// 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<UserAppData | null> => {
// 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<UserAppData | null> => {
// 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<AxiosResponse<FileServerPutResponse>>[] = []
// 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<FileServerPutResponse>(
`${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

View File

@ -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 = <T extends (...args: never[]) => void>(
fn: T,
@ -143,3 +144,25 @@ export const isPromiseRejected = <T>(
): 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
)
}